+
+
+ \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'create_item' ),
+ 'permission_callback' => array( $this, 'create_item_permissions_check' ),
+ ),
'schema' => array( $this, 'get_item_schema' ),
)
);
@@ -317,4 +327,201 @@ public function overload_total_items( $response, $request ) {
return $response;
}
+
+ /**
+ * Permission check for creating items (C2S).
+ *
+ * @param \WP_REST_Request $request Full details about the request.
+ * @return bool|\WP_Error True if authorized, WP_Error otherwise.
+ */
+ public function create_item_permissions_check( \WP_REST_Request $request ) {
+ // Check if C2S is enabled.
+ if ( ! OAuth_Server::is_c2s_enabled() ) {
+ return new \WP_Error(
+ 'activitypub_c2s_disabled',
+ \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ // Must be authenticated via OAuth with 'write' scope.
+ $permission = OAuth_Server::check_oauth_permission( $request, Scope::WRITE );
+ if ( \is_wp_error( $permission ) ) {
+ return $permission;
+ }
+
+ // Token user must match actor in URL.
+ $user_id = $request->get_param( 'user_id' );
+ $token = OAuth_Server::get_current_token();
+
+ if ( ! $token || $token->get_user_id() !== $user_id ) {
+ return new \WP_Error(
+ 'activitypub_forbidden',
+ \__( 'You can only post to your own outbox.', 'activitypub' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Create an item in the outbox (C2S).
+ *
+ * Follows the same pattern as the Inbox controller:
+ * 1. Store the activity in the outbox
+ * 2. Trigger action hooks for handlers to process
+ *
+ * @param \WP_REST_Request $request Full details about the request.
+ * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error on failure.
+ */
+ public function create_item( \WP_REST_Request $request ) {
+ $user_id = $request->get_param( 'user_id' );
+ $user = Actors::get_by_id( $user_id );
+ $data = $request->get_json_params();
+
+ if ( empty( $data ) ) {
+ return new \WP_Error(
+ 'activitypub_invalid_request',
+ \__( 'Request body must be a valid ActivityPub object or activity.', 'activitypub' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Determine if this is an Activity or a bare Object.
+ $type = $data['type'] ?? '';
+ $is_activity = in_array( $type, Activity::TYPES, true );
+
+ // If it's a bare object, wrap it in a Create activity.
+ if ( ! $is_activity ) {
+ $data = $this->wrap_in_create( $data, $user );
+ }
+
+ $activity_type = camel_to_snake_case( $data['type'] ?? '' );
+
+ // Determine visibility from addressing.
+ $visibility = $this->determine_visibility( $data );
+
+ // Add to outbox - this handles storage and triggers federation.
+ $outbox_id = add_to_outbox( $data, null, $user_id, $visibility );
+
+ if ( ! $outbox_id || \is_wp_error( $outbox_id ) ) {
+ return new \WP_Error(
+ 'activitypub_outbox_error',
+ \__( 'Failed to add activity to outbox.', 'activitypub' ),
+ array( 'status' => 500 )
+ );
+ }
+
+ // Get the stored activity for hooks.
+ $activity = Outbox::get_activity( $outbox_id );
+
+ /**
+ * Fires for each outbox activity.
+ *
+ * @param array $data The activity data array.
+ * @param int $user_id The user ID.
+ * @param string $type The activity type (snake_case).
+ * @param \Activitypub\Activity\Activity $activity The Activity object.
+ */
+ \do_action( 'activitypub_outbox', $data, $user_id, $activity_type, $activity );
+
+ /**
+ * Fires for specific outbox activity types.
+ *
+ * The dynamic portion of the hook name, `$activity_type`, refers to the
+ * activity type in snake_case (e.g., 'create', 'update', 'delete', 'like').
+ *
+ * @param array $data The activity data array.
+ * @param int $user_id The user ID.
+ * @param \Activitypub\Activity\Activity $activity The Activity object.
+ */
+ \do_action( 'activitypub_outbox_' . $activity_type, $data, $user_id, $activity );
+
+ /**
+ * Fires after an outbox activity has been stored.
+ *
+ * @param array $data The activity data array.
+ * @param int $user_id The user ID.
+ * @param string $type The activity type (snake_case).
+ * @param \Activitypub\Activity\Activity $activity The Activity object.
+ * @param int $outbox_id The outbox post ID.
+ */
+ \do_action( 'activitypub_handled_outbox', $data, $user_id, $activity_type, $activity, $outbox_id );
+
+ /**
+ * Fires after a specific outbox activity type has been stored.
+ *
+ * @param array $data The activity data array.
+ * @param int $user_id The user ID.
+ * @param \Activitypub\Activity\Activity $activity The Activity object.
+ * @param int $outbox_id The outbox post ID.
+ */
+ \do_action( 'activitypub_handled_outbox_' . $activity_type, $data, $user_id, $activity, $outbox_id );
+
+ if ( \is_wp_error( $activity ) ) {
+ return $activity;
+ }
+
+ $result = $activity->to_array( false );
+
+ // Return 201 Created with Location header.
+ $response = new \WP_REST_Response( $result, 201 );
+ $response->header( 'Location', $result['id'] ?? \get_the_guid( $outbox_id ) );
+ $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
+
+ return $response;
+ }
+
+ /**
+ * Wrap a bare object in a Create activity.
+ *
+ * @param array $object The object data.
+ * @param mixed $user The user/actor.
+ * @return array The wrapped Create activity.
+ */
+ private function wrap_in_create( $object, $user ) {
+ // Copy addressing from object to activity.
+ $addressing = array();
+ foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $field ) {
+ if ( ! empty( $object[ $field ] ) ) {
+ $addressing[ $field ] = $object[ $field ];
+ }
+ }
+
+ return array_merge(
+ array(
+ '@context' => Base_Object::JSON_LD_CONTEXT,
+ 'type' => 'Create',
+ 'actor' => $user->get_id(),
+ 'object' => $object,
+ ),
+ $addressing
+ );
+ }
+
+ /**
+ * Determine content visibility from activity addressing.
+ *
+ * @param array $activity The activity data.
+ * @return string Visibility constant.
+ */
+ private function determine_visibility( $activity ) {
+ $public = 'https://www.w3.org/ns/activitystreams#Public';
+ $to = (array) ( $activity['to'] ?? array() );
+ $cc = (array) ( $activity['cc'] ?? array() );
+
+ // Check if public.
+ if ( in_array( $public, $to, true ) ) {
+ return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC;
+ }
+
+ // Check if unlisted (public in cc).
+ if ( in_array( $public, $cc, true ) ) {
+ return ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC;
+ }
+
+ // Private (no public addressing).
+ return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE;
+ }
}
diff --git a/includes/wp-admin/class-advanced-settings-fields.php b/includes/wp-admin/class-advanced-settings-fields.php
index 5efa7aff77..20772f73c5 100644
--- a/includes/wp-admin/class-advanced-settings-fields.php
+++ b/includes/wp-admin/class-advanced-settings-fields.php
@@ -98,6 +98,15 @@ public static function register_advanced_fields() {
'activitypub_advanced_settings',
array( 'label_for' => 'activitypub_object_type' )
);
+
+ \add_settings_field(
+ 'activitypub_enable_c2s',
+ \__( 'Client-to-Server (C2S)', 'activitypub' ),
+ array( self::class, 'render_enable_c2s_field' ),
+ 'activitypub_advanced_settings',
+ 'activitypub_advanced_settings',
+ array( 'label_for' => 'activitypub_enable_c2s' )
+ );
}
/**
@@ -253,4 +262,35 @@ public static function render_object_type_field() {
+
+
+
+
+
+
+
+ SWICG ActivityPub API specification, which is still under development. Some features may change in future versions.', 'activitypub' ),
+ array(
+ 'a' => array(
+ 'href' => true,
+ 'target' => true,
+ ),
+ )
+ );
+ ?>
+
+
Date: Sun, 1 Feb 2026 00:10:11 +0100
Subject: [PATCH 002/105] Fix PHPCS warnings in OAuth and outbox controller
---
includes/oauth/class-authorization-code.php | 1 +
includes/oauth/class-client.php | 4 ++++
includes/oauth/class-token.php | 2 ++
includes/rest/class-outbox-controller.php | 12 ++++++------
4 files changed, 13 insertions(+), 6 deletions(-)
diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php
index 33e7340086..da927eee67 100644
--- a/includes/oauth/class-authorization-code.php
+++ b/includes/oauth/class-authorization-code.php
@@ -250,6 +250,7 @@ public static function verify_pkce( $code_verifier, $code_challenge, $method = '
*/
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 ), '+/', '-_' ), '=' );
}
diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php
index 37827d000f..c09628727a 100644
--- a/includes/oauth/class-client.php
+++ b/includes/oauth/class-client.php
@@ -129,6 +129,7 @@ public static function register( $data ) {
* @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,
@@ -138,6 +139,7 @@ public static function get( $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 ) ) {
return new \WP_Error(
@@ -325,6 +327,7 @@ public static function delete( $client_id ) {
}
// Delete all tokens for this client.
+ // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Token cleanup by client ID is necessary.
$tokens = \get_posts(
array(
'post_type' => Token::POST_TYPE,
@@ -334,6 +337,7 @@ public static function delete( $client_id ) {
'fields' => 'ids',
)
);
+ // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
foreach ( $tokens as $token_id ) {
\wp_delete_post( $token_id, true );
diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php
index 03bb845b5a..6eb0f33ab1 100644
--- a/includes/oauth/class-token.php
+++ b/includes/oauth/class-token.php
@@ -111,6 +111,7 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D
public static function validate( $token ) {
$hash = self::hash_token( $token );
+ // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Token lookup by hash is necessary.
$posts = \get_posts(
array(
'post_type' => self::POST_TYPE,
@@ -120,6 +121,7 @@ public static function validate( $token ) {
'numberposts' => 1,
)
);
+ // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
if ( empty( $posts ) ) {
return new \WP_Error(
diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php
index c4daead459..22644d0223 100644
--- a/includes/rest/class-outbox-controller.php
+++ b/includes/rest/class-outbox-controller.php
@@ -476,16 +476,16 @@ public function create_item( \WP_REST_Request $request ) {
/**
* Wrap a bare object in a Create activity.
*
- * @param array $object The object data.
- * @param mixed $user The user/actor.
+ * @param array $object_data The object data.
+ * @param mixed $user The user/actor.
* @return array The wrapped Create activity.
*/
- private function wrap_in_create( $object, $user ) {
+ private function wrap_in_create( $object_data, $user ) {
// Copy addressing from object to activity.
$addressing = array();
foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $field ) {
- if ( ! empty( $object[ $field ] ) ) {
- $addressing[ $field ] = $object[ $field ];
+ if ( ! empty( $object_data[ $field ] ) ) {
+ $addressing[ $field ] = $object_data[ $field ];
}
}
@@ -494,7 +494,7 @@ private function wrap_in_create( $object, $user ) {
'@context' => Base_Object::JSON_LD_CONTEXT,
'type' => 'Create',
'actor' => $user->get_id(),
- 'object' => $object,
+ 'object' => $object_data,
),
$addressing
);
From 51bd49b353d577a4681eed4383cb0ce8efea657e Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 1 Feb 2026 00:20:02 +0100
Subject: [PATCH 003/105] Add outbox handlers for Like and Announce activities
Add C2S support for Like and Announce activities by hooking into the
activitypub_handled_outbox_like and activitypub_handled_outbox_announce
actions. These handlers fire corresponding sent actions that can be used
to track when activities are sent via C2S.
---
includes/handler/class-announce.php | 29 +++++++++++++++++++++++++++++
includes/handler/class-like.php | 29 +++++++++++++++++++++++++++++
2 files changed, 58 insertions(+)
diff --git a/includes/handler/class-announce.php b/includes/handler/class-announce.php
index 9f58ba7fc3..91d0c7f022 100644
--- a/includes/handler/class-announce.php
+++ b/includes/handler/class-announce.php
@@ -25,6 +25,7 @@ class Announce {
*/
public static function init() {
\add_action( 'activitypub_inbox_announce', array( self::class, 'handle_announce' ), 10, 3 );
+ \add_action( 'activitypub_handled_outbox_announce', array( self::class, 'handle_outbox_announce' ), 10, 4 );
}
/**
@@ -130,4 +131,32 @@ public static function maybe_save_announce( $activity, $user_ids ) {
*/
\do_action( 'activitypub_handled_announce', $activity, (array) $user_ids, $success, $result );
}
+
+ /**
+ * Handle outbox "Announce" activities (C2S).
+ *
+ * Records an announce/boost from the local user on remote content.
+ *
+ * @param array $data The activity data array.
+ * @param int $user_id The user ID.
+ * @param \Activitypub\Activity\Activity $activity The Activity object.
+ * @param int $outbox_id The outbox post ID.
+ */
+ public static function handle_outbox_announce( $data, $user_id, $activity, $outbox_id ) {
+ $object_url = object_to_uri( $data['object'] ?? '' );
+
+ if ( empty( $object_url ) ) {
+ return;
+ }
+
+ /**
+ * Fires after an Announce activity has been sent via C2S.
+ *
+ * @param string $object_url The URL of the announced object.
+ * @param array $data The activity data.
+ * @param int $user_id The user ID.
+ * @param int $outbox_id The outbox post ID.
+ */
+ \do_action( 'activitypub_outbox_announce_sent', $object_url, $data, $user_id, $outbox_id );
+ }
}
diff --git a/includes/handler/class-like.php b/includes/handler/class-like.php
index 1d8912a7d8..fe77e36b94 100644
--- a/includes/handler/class-like.php
+++ b/includes/handler/class-like.php
@@ -21,6 +21,7 @@ class Like {
*/
public static function init() {
\add_action( 'activitypub_inbox_like', array( self::class, 'handle_like' ), 10, 2 );
+ \add_action( 'activitypub_handled_outbox_like', array( self::class, 'handle_outbox_like' ), 10, 4 );
\add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) );
}
@@ -65,6 +66,34 @@ public static function handle_like( $like, $user_ids ) {
\do_action( 'activitypub_handled_like', $like, (array) $user_ids, $success, $result );
}
+ /**
+ * Handle outbox "Like" activities (C2S).
+ *
+ * Records a like from the local user on remote content.
+ *
+ * @param array $data The activity data array.
+ * @param int $user_id The user ID.
+ * @param \Activitypub\Activity\Activity $activity The Activity object.
+ * @param int $outbox_id The outbox post ID.
+ */
+ public static function handle_outbox_like( $data, $user_id, $activity, $outbox_id ) {
+ $object_url = object_to_uri( $data['object'] ?? '' );
+
+ if ( empty( $object_url ) ) {
+ return;
+ }
+
+ /**
+ * Fires after a Like activity has been sent via C2S.
+ *
+ * @param string $object_url The URL of the liked object.
+ * @param array $data The activity data.
+ * @param int $user_id The user ID.
+ * @param int $outbox_id The outbox post ID.
+ */
+ \do_action( 'activitypub_outbox_like_sent', $object_url, $data, $user_id, $outbox_id );
+ }
+
/**
* Set the object to the object ID.
*
From c99d88135535ea3c60dc2b9aba1532cbc209b7a2 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 1 Feb 2026 00:24:04 +0100
Subject: [PATCH 004/105] Add PHPUnit tests for OAuth classes
Add comprehensive test coverage for the OAuth infrastructure:
- Test_Scope: Scope parsing, validation, and string conversion
- Test_Token: Token creation, validation, refresh, and revocation
- Test_Client: Client registration, validation, and scope filtering
- Test_Authorization_Code: PKCE flow, code exchange, and security checks
---
.../oauth/class-test-authorization-code.php | 493 +++++++++++++++++
.../includes/oauth/class-test-client.php | 507 ++++++++++++++++++
.../tests/includes/oauth/class-test-scope.php | 303 +++++++++++
.../tests/includes/oauth/class-test-token.php | 353 ++++++++++++
4 files changed, 1656 insertions(+)
create mode 100644 tests/phpunit/tests/includes/oauth/class-test-authorization-code.php
create mode 100644 tests/phpunit/tests/includes/oauth/class-test-client.php
create mode 100644 tests/phpunit/tests/includes/oauth/class-test-scope.php
create mode 100644 tests/phpunit/tests/includes/oauth/class-test-token.php
diff --git a/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php b/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php
new file mode 100644
index 0000000000..5c59f5aa06
--- /dev/null
+++ b/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php
@@ -0,0 +1,493 @@
+user_id = $this->factory->user->create(
+ array(
+ 'role' => 'editor',
+ )
+ );
+
+ // Create a test client.
+ $client_result = Client::register(
+ array(
+ 'name' => 'Test Client',
+ 'redirect_uris' => array( $this->redirect_uri ),
+ )
+ );
+ $this->client_id = $client_result['client_id'];
+ }
+
+ /**
+ * Tear down the test.
+ */
+ public function tear_down() {
+ // Clean up client.
+ if ( $this->client_id ) {
+ Client::delete( $this->client_id );
+ }
+
+ parent::tear_down();
+ }
+
+ /**
+ * Generate a PKCE code verifier.
+ *
+ * @return string
+ */
+ protected function generate_code_verifier() {
+ return bin2hex( random_bytes( 32 ) );
+ }
+
+ /**
+ * Test compute_code_challenge method.
+ *
+ * @covers ::compute_code_challenge
+ */
+ public function test_compute_code_challenge() {
+ $verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
+ $challenge = Authorization_Code::compute_code_challenge( $verifier );
+
+ // Verify BASE64URL encoding (no +, /, or = characters).
+ $this->assertDoesNotMatchRegularExpression( '/[+\/=]/', $challenge );
+
+ // Should be a valid S256 challenge.
+ $this->assertNotEmpty( $challenge );
+ }
+
+ /**
+ * Test verify_pkce method with S256.
+ *
+ * @covers ::verify_pkce
+ */
+ public function test_verify_pkce_s256() {
+ $verifier = $this->generate_code_verifier();
+ $challenge = Authorization_Code::compute_code_challenge( $verifier );
+
+ $this->assertTrue( Authorization_Code::verify_pkce( $verifier, $challenge, 'S256' ) );
+ $this->assertFalse( Authorization_Code::verify_pkce( 'wrong_verifier', $challenge, 'S256' ) );
+ }
+
+ /**
+ * Test verify_pkce method with plain.
+ *
+ * @covers ::verify_pkce
+ */
+ public function test_verify_pkce_plain() {
+ $verifier = $this->generate_code_verifier();
+
+ $this->assertTrue( Authorization_Code::verify_pkce( $verifier, $verifier, 'plain' ) );
+ $this->assertFalse( Authorization_Code::verify_pkce( 'wrong_verifier', $verifier, 'plain' ) );
+ }
+
+ /**
+ * Test verify_pkce method with empty values.
+ *
+ * @covers ::verify_pkce
+ */
+ public function test_verify_pkce_empty() {
+ $this->assertFalse( Authorization_Code::verify_pkce( '', 'challenge', 'S256' ) );
+ $this->assertFalse( Authorization_Code::verify_pkce( 'verifier', '', 'S256' ) );
+ $this->assertFalse( Authorization_Code::verify_pkce( '', '', 'S256' ) );
+ }
+
+ /**
+ * Test generate_code method.
+ *
+ * @covers ::generate_code
+ */
+ public function test_generate_code() {
+ $code = Authorization_Code::generate_code();
+
+ // Should be 64 hex characters (32 bytes).
+ $this->assertEquals( 64, strlen( $code ) );
+ $this->assertMatchesRegularExpression( '/^[0-9a-f]+$/', $code );
+ }
+
+ /**
+ * Test create method.
+ *
+ * @covers ::create
+ */
+ public function test_create() {
+ $verifier = $this->generate_code_verifier();
+ $challenge = Authorization_Code::compute_code_challenge( $verifier );
+
+ $code = Authorization_Code::create(
+ $this->user_id,
+ $this->client_id,
+ $this->redirect_uri,
+ array( Scope::READ, Scope::WRITE ),
+ $challenge,
+ 'S256'
+ );
+
+ $this->assertIsString( $code );
+ $this->assertEquals( 64, strlen( $code ) );
+ }
+
+ /**
+ * Test create method with invalid client.
+ *
+ * @covers ::create
+ */
+ public function test_create_invalid_client() {
+ $result = Authorization_Code::create(
+ $this->user_id,
+ 'invalid-client-id',
+ $this->redirect_uri,
+ array( Scope::READ ),
+ 'challenge',
+ 'S256'
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'activitypub_client_not_found', $result->get_error_code() );
+ }
+
+ /**
+ * Test create method with invalid redirect URI.
+ *
+ * @covers ::create
+ */
+ public function test_create_invalid_redirect_uri() {
+ $result = Authorization_Code::create(
+ $this->user_id,
+ $this->client_id,
+ 'https://other.com/callback',
+ array( Scope::READ ),
+ 'challenge',
+ 'S256'
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'activitypub_invalid_redirect_uri', $result->get_error_code() );
+ }
+
+ /**
+ * Test exchange method.
+ *
+ * @covers ::exchange
+ */
+ public function test_exchange() {
+ $verifier = $this->generate_code_verifier();
+ $challenge = Authorization_Code::compute_code_challenge( $verifier );
+
+ $code = Authorization_Code::create(
+ $this->user_id,
+ $this->client_id,
+ $this->redirect_uri,
+ array( Scope::READ, Scope::WRITE ),
+ $challenge,
+ 'S256'
+ );
+
+ $result = Authorization_Code::exchange(
+ $code,
+ $this->client_id,
+ $this->redirect_uri,
+ $verifier
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertArrayHasKey( 'access_token', $result );
+ $this->assertArrayHasKey( 'refresh_token', $result );
+ $this->assertArrayHasKey( 'token_type', $result );
+ $this->assertArrayHasKey( 'expires_in', $result );
+ $this->assertArrayHasKey( 'scope', $result );
+
+ $this->assertEquals( 'Bearer', $result['token_type'] );
+ }
+
+ /**
+ * Test exchange method prevents code reuse.
+ *
+ * @covers ::exchange
+ */
+ public function test_exchange_prevents_reuse() {
+ $verifier = $this->generate_code_verifier();
+ $challenge = Authorization_Code::compute_code_challenge( $verifier );
+
+ $code = Authorization_Code::create(
+ $this->user_id,
+ $this->client_id,
+ $this->redirect_uri,
+ array( Scope::READ ),
+ $challenge,
+ 'S256'
+ );
+
+ // First exchange should succeed.
+ $result1 = Authorization_Code::exchange(
+ $code,
+ $this->client_id,
+ $this->redirect_uri,
+ $verifier
+ );
+ $this->assertIsArray( $result1 );
+
+ // Second exchange with same code should fail.
+ $result2 = Authorization_Code::exchange(
+ $code,
+ $this->client_id,
+ $this->redirect_uri,
+ $verifier
+ );
+ $this->assertInstanceOf( \WP_Error::class, $result2 );
+ $this->assertEquals( 'activitypub_invalid_code', $result2->get_error_code() );
+ }
+
+ /**
+ * Test exchange method with invalid code.
+ *
+ * @covers ::exchange
+ */
+ public function test_exchange_invalid_code() {
+ $result = Authorization_Code::exchange(
+ 'invalid_code',
+ $this->client_id,
+ $this->redirect_uri,
+ 'verifier'
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'activitypub_invalid_code', $result->get_error_code() );
+ }
+
+ /**
+ * Test exchange method with wrong redirect URI.
+ *
+ * @covers ::exchange
+ */
+ public function test_exchange_redirect_uri_mismatch() {
+ $verifier = $this->generate_code_verifier();
+ $challenge = Authorization_Code::compute_code_challenge( $verifier );
+
+ $code = Authorization_Code::create(
+ $this->user_id,
+ $this->client_id,
+ $this->redirect_uri,
+ array( Scope::READ ),
+ $challenge,
+ 'S256'
+ );
+
+ $result = Authorization_Code::exchange(
+ $code,
+ $this->client_id,
+ 'https://other.com/callback', // Wrong redirect URI.
+ $verifier
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'activitypub_redirect_uri_mismatch', $result->get_error_code() );
+ }
+
+ /**
+ * Test exchange method with wrong PKCE verifier.
+ *
+ * @covers ::exchange
+ */
+ public function test_exchange_invalid_pkce() {
+ $verifier = $this->generate_code_verifier();
+ $challenge = Authorization_Code::compute_code_challenge( $verifier );
+
+ $code = Authorization_Code::create(
+ $this->user_id,
+ $this->client_id,
+ $this->redirect_uri,
+ array( Scope::READ ),
+ $challenge,
+ 'S256'
+ );
+
+ $result = Authorization_Code::exchange(
+ $code,
+ $this->client_id,
+ $this->redirect_uri,
+ 'wrong_verifier' // Wrong PKCE verifier.
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'activitypub_invalid_pkce', $result->get_error_code() );
+ }
+
+ /**
+ * Test exchange method with wrong client ID.
+ *
+ * @covers ::exchange
+ */
+ public function test_exchange_wrong_client() {
+ $verifier = $this->generate_code_verifier();
+ $challenge = Authorization_Code::compute_code_challenge( $verifier );
+
+ $code = Authorization_Code::create(
+ $this->user_id,
+ $this->client_id,
+ $this->redirect_uri,
+ array( Scope::READ ),
+ $challenge,
+ 'S256'
+ );
+
+ $result = Authorization_Code::exchange(
+ $code,
+ 'wrong-client-id',
+ $this->redirect_uri,
+ $verifier
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'activitypub_invalid_code', $result->get_error_code() );
+ }
+
+ /**
+ * Test exchange method filters scopes to client allowed scopes.
+ *
+ * @covers ::exchange
+ * @covers ::create
+ */
+ public function test_exchange_filters_scopes() {
+ // Create a client with limited scopes.
+ $limited_client = Client::register(
+ array(
+ 'name' => 'Limited Client',
+ 'redirect_uris' => array( 'https://limited.com/callback' ),
+ 'scopes' => array( Scope::READ ),
+ )
+ );
+
+ $verifier = $this->generate_code_verifier();
+ $challenge = Authorization_Code::compute_code_challenge( $verifier );
+
+ // Request more scopes than allowed.
+ $code = Authorization_Code::create(
+ $this->user_id,
+ $limited_client['client_id'],
+ 'https://limited.com/callback',
+ array( Scope::READ, Scope::WRITE, Scope::FOLLOW ),
+ $challenge,
+ 'S256'
+ );
+
+ $result = Authorization_Code::exchange(
+ $code,
+ $limited_client['client_id'],
+ 'https://limited.com/callback',
+ $verifier
+ );
+
+ // Should only have the allowed scope.
+ $this->assertIsArray( $result );
+ $this->assertEquals( 'read', $result['scope'] );
+
+ // Clean up.
+ Client::delete( $limited_client['client_id'] );
+ }
+
+ /**
+ * Test cleanup method.
+ *
+ * @covers ::cleanup
+ */
+ public function test_cleanup() {
+ // Cleanup should run without errors.
+ $count = Authorization_Code::cleanup();
+ $this->assertIsInt( $count );
+ }
+
+ /**
+ * Test complete PKCE flow.
+ *
+ * @covers ::create
+ * @covers ::exchange
+ * @covers ::verify_pkce
+ * @covers ::compute_code_challenge
+ */
+ public function test_complete_pkce_flow() {
+ // Step 1: Client generates code verifier and challenge.
+ $code_verifier = $this->generate_code_verifier();
+ $code_challenge = Authorization_Code::compute_code_challenge( $code_verifier );
+
+ // Step 2: Server creates authorization code.
+ $code = Authorization_Code::create(
+ $this->user_id,
+ $this->client_id,
+ $this->redirect_uri,
+ array( Scope::READ, Scope::WRITE ),
+ $code_challenge,
+ 'S256'
+ );
+
+ $this->assertIsString( $code );
+
+ // Step 3: Client exchanges code for tokens.
+ $tokens = Authorization_Code::exchange(
+ $code,
+ $this->client_id,
+ $this->redirect_uri,
+ $code_verifier
+ );
+
+ $this->assertIsArray( $tokens );
+ $this->assertArrayHasKey( 'access_token', $tokens );
+ $this->assertArrayHasKey( 'refresh_token', $tokens );
+
+ // Step 4: Verify the access token works.
+ $token = Token::validate( $tokens['access_token'] );
+ $this->assertInstanceOf( Token::class, $token );
+ $this->assertEquals( $this->user_id, $token->get_user_id() );
+ $this->assertEquals( $this->client_id, $token->get_client_id() );
+ $this->assertTrue( $token->has_scope( Scope::READ ) );
+ $this->assertTrue( $token->has_scope( Scope::WRITE ) );
+ }
+}
diff --git a/tests/phpunit/tests/includes/oauth/class-test-client.php b/tests/phpunit/tests/includes/oauth/class-test-client.php
new file mode 100644
index 0000000000..cd3c117f59
--- /dev/null
+++ b/tests/phpunit/tests/includes/oauth/class-test-client.php
@@ -0,0 +1,507 @@
+created_clients as $client_id ) {
+ Client::delete( $client_id );
+ }
+ $this->created_clients = array();
+
+ parent::tear_down();
+ }
+
+ /**
+ * Helper to create a client and track it for cleanup.
+ *
+ * @param array $data Client registration data.
+ * @return array|WP_Error Client credentials.
+ */
+ protected function create_client( $data ) {
+ $result = Client::register( $data );
+ if ( ! is_wp_error( $result ) ) {
+ $this->created_clients[] = $result['client_id'];
+ }
+ return $result;
+ }
+
+ /**
+ * Test generate_client_id produces UUID v4 format.
+ *
+ * @covers ::generate_client_id
+ */
+ public function test_generate_client_id() {
+ $client_id = Client::generate_client_id();
+
+ // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx.
+ $this->assertMatchesRegularExpression(
+ '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/',
+ $client_id
+ );
+ }
+
+ /**
+ * Test generate_client_secret.
+ *
+ * @covers ::generate_client_secret
+ */
+ public function test_generate_client_secret() {
+ $secret = Client::generate_client_secret();
+
+ // Should be 64 hex characters (32 bytes).
+ $this->assertEquals( 64, strlen( $secret ) );
+ $this->assertMatchesRegularExpression( '/^[0-9a-f]+$/', $secret );
+ }
+
+ /**
+ * Test register method creates public client.
+ *
+ * @covers ::register
+ */
+ public function test_register_public_client() {
+ $result = $this->create_client(
+ array(
+ 'name' => 'Test Public Client',
+ 'redirect_uris' => array( 'https://example.com/callback' ),
+ 'is_public' => true,
+ )
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertArrayHasKey( 'client_id', $result );
+ $this->assertArrayNotHasKey( 'client_secret', $result );
+ }
+
+ /**
+ * Test register method creates confidential client.
+ *
+ * @covers ::register
+ */
+ public function test_register_confidential_client() {
+ $result = $this->create_client(
+ array(
+ 'name' => 'Test Confidential Client',
+ 'redirect_uris' => array( 'https://example.com/callback' ),
+ 'is_public' => false,
+ )
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertArrayHasKey( 'client_id', $result );
+ $this->assertArrayHasKey( 'client_secret', $result );
+ }
+
+ /**
+ * Test register method requires name.
+ *
+ * @covers ::register
+ */
+ public function test_register_requires_name() {
+ $result = Client::register(
+ array(
+ 'redirect_uris' => array( 'https://example.com/callback' ),
+ )
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'activitypub_missing_client_name', $result->get_error_code() );
+ }
+
+ /**
+ * Test register method requires redirect_uris.
+ *
+ * @covers ::register
+ */
+ public function test_register_requires_redirect_uris() {
+ $result = Client::register(
+ array(
+ 'name' => 'Test Client',
+ )
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'activitypub_missing_redirect_uri', $result->get_error_code() );
+ }
+
+ /**
+ * Test register method validates redirect URI format.
+ *
+ * @covers ::register
+ */
+ public function test_register_validates_redirect_uri_https() {
+ $result = Client::register(
+ array(
+ 'name' => 'Test Client',
+ 'redirect_uris' => array( 'http://example.com/callback' ),
+ )
+ );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'activitypub_invalid_redirect_uri', $result->get_error_code() );
+ }
+
+ /**
+ * Test register method allows http for localhost.
+ *
+ * @covers ::register
+ */
+ public function test_register_allows_localhost_http() {
+ $result = $this->create_client(
+ array(
+ 'name' => 'Localhost Client',
+ 'redirect_uris' => array( 'http://localhost:8080/callback' ),
+ )
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertArrayHasKey( 'client_id', $result );
+ }
+
+ /**
+ * Test register method allows http for 127.0.0.1.
+ *
+ * @covers ::register
+ */
+ public function test_register_allows_loopback_http() {
+ $result = $this->create_client(
+ array(
+ 'name' => 'Loopback Client',
+ 'redirect_uris' => array( 'http://127.0.0.1:3000/callback' ),
+ )
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertArrayHasKey( 'client_id', $result );
+ }
+
+ /**
+ * Test get method retrieves client.
+ *
+ * @covers ::get
+ */
+ public function test_get_client() {
+ $result = $this->create_client(
+ array(
+ 'name' => 'Test Client',
+ 'redirect_uris' => array( 'https://example.com/callback' ),
+ )
+ );
+
+ $client = Client::get( $result['client_id'] );
+
+ $this->assertInstanceOf( Client::class, $client );
+ $this->assertEquals( 'Test Client', $client->get_name() );
+ }
+
+ /**
+ * Test get method returns error for non-existent client.
+ *
+ * @covers ::get
+ */
+ public function test_get_nonexistent_client() {
+ $result = Client::get( 'nonexistent-client-id' );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'activitypub_client_not_found', $result->get_error_code() );
+ }
+
+ /**
+ * Test validate method for public client.
+ *
+ * @covers ::validate
+ */
+ public function test_validate_public_client() {
+ $result = $this->create_client(
+ array(
+ 'name' => 'Public Client',
+ 'redirect_uris' => array( 'https://example.com/callback' ),
+ 'is_public' => true,
+ )
+ );
+
+ // Public clients don't need a secret.
+ $this->assertTrue( Client::validate( $result['client_id'] ) );
+ $this->assertTrue( Client::validate( $result['client_id'], null ) );
+ }
+
+ /**
+ * Test validate method for confidential client.
+ *
+ * @covers ::validate
+ */
+ public function test_validate_confidential_client() {
+ $result = $this->create_client(
+ array(
+ 'name' => 'Confidential Client',
+ 'redirect_uris' => array( 'https://example.com/callback' ),
+ 'is_public' => false,
+ )
+ );
+
+ // Valid secret should pass.
+ $this->assertTrue( Client::validate( $result['client_id'], $result['client_secret'] ) );
+
+ // No secret should fail.
+ $this->assertFalse( Client::validate( $result['client_id'] ) );
+
+ // Wrong secret should fail.
+ $this->assertFalse( Client::validate( $result['client_id'], 'wrong_secret' ) );
+ }
+
+ /**
+ * Test validate method for non-existent client.
+ *
+ * @covers ::validate
+ */
+ public function test_validate_nonexistent_client() {
+ $this->assertFalse( Client::validate( 'nonexistent-client-id' ) );
+ }
+
+ /**
+ * Test is_valid_redirect_uri method.
+ *
+ * @covers ::is_valid_redirect_uri
+ */
+ public function test_is_valid_redirect_uri() {
+ $result = $this->create_client(
+ array(
+ 'name' => 'Test Client',
+ 'redirect_uris' => array(
+ 'https://example.com/callback',
+ 'https://example.com/oauth',
+ ),
+ )
+ );
+
+ $client = Client::get( $result['client_id'] );
+
+ $this->assertTrue( $client->is_valid_redirect_uri( 'https://example.com/callback' ) );
+ $this->assertTrue( $client->is_valid_redirect_uri( 'https://example.com/oauth' ) );
+ $this->assertFalse( $client->is_valid_redirect_uri( 'https://example.com/other' ) );
+ $this->assertFalse( $client->is_valid_redirect_uri( 'https://other.com/callback' ) );
+ }
+
+ /**
+ * Test get_redirect_uris method.
+ *
+ * @covers ::get_redirect_uris
+ */
+ public function test_get_redirect_uris() {
+ $uris = array(
+ 'https://example.com/callback',
+ 'https://example.com/oauth',
+ );
+ $result = $this->create_client(
+ array(
+ 'name' => 'Test Client',
+ 'redirect_uris' => $uris,
+ )
+ );
+
+ $client = Client::get( $result['client_id'] );
+
+ $this->assertEquals( $uris, $client->get_redirect_uris() );
+ }
+
+ /**
+ * Test get_allowed_scopes method.
+ *
+ * @covers ::get_allowed_scopes
+ */
+ public function test_get_allowed_scopes() {
+ $scopes = array( Scope::READ, Scope::WRITE );
+ $result = $this->create_client(
+ array(
+ 'name' => 'Test Client',
+ 'redirect_uris' => array( 'https://example.com/callback' ),
+ 'scopes' => $scopes,
+ )
+ );
+
+ $client = Client::get( $result['client_id'] );
+
+ $this->assertEquals( $scopes, $client->get_allowed_scopes() );
+ }
+
+ /**
+ * Test get_allowed_scopes defaults to all scopes.
+ *
+ * @covers ::get_allowed_scopes
+ */
+ public function test_get_allowed_scopes_default() {
+ $result = $this->create_client(
+ array(
+ 'name' => 'Test Client',
+ 'redirect_uris' => array( 'https://example.com/callback' ),
+ )
+ );
+
+ $client = Client::get( $result['client_id'] );
+
+ $this->assertEquals( Scope::ALL, $client->get_allowed_scopes() );
+ }
+
+ /**
+ * Test is_public method.
+ *
+ * @covers ::is_public
+ */
+ public function test_is_public() {
+ $public_result = $this->create_client(
+ array(
+ 'name' => 'Public Client',
+ 'redirect_uris' => array( 'https://example.com/callback' ),
+ 'is_public' => true,
+ )
+ );
+
+ $confidential_result = $this->create_client(
+ array(
+ 'name' => 'Confidential Client',
+ 'redirect_uris' => array( 'https://other.com/callback' ),
+ 'is_public' => false,
+ )
+ );
+
+ $public_client = Client::get( $public_result['client_id'] );
+ $confidential_client = Client::get( $confidential_result['client_id'] );
+
+ $this->assertTrue( $public_client->is_public() );
+ $this->assertFalse( $confidential_client->is_public() );
+ }
+
+ /**
+ * Test filter_scopes method.
+ *
+ * @covers ::filter_scopes
+ */
+ public function test_filter_scopes() {
+ $result = $this->create_client(
+ array(
+ 'name' => 'Limited Client',
+ 'redirect_uris' => array( 'https://example.com/callback' ),
+ 'scopes' => array( Scope::READ, Scope::WRITE ),
+ )
+ );
+
+ $client = Client::get( $result['client_id'] );
+
+ $filtered = $client->filter_scopes( array( Scope::READ, Scope::FOLLOW, Scope::WRITE ) );
+ $this->assertEquals( array( Scope::READ, Scope::WRITE ), $filtered );
+
+ $filtered = $client->filter_scopes( array( Scope::FOLLOW, Scope::PUSH ) );
+ $this->assertEquals( array(), $filtered );
+ }
+
+ /**
+ * Test delete method.
+ *
+ * @covers ::delete
+ */
+ public function test_delete() {
+ $result = Client::register(
+ array(
+ 'name' => 'Delete Test Client',
+ 'redirect_uris' => array( 'https://example.com/callback' ),
+ )
+ );
+ $client_id = $result['client_id'];
+
+ // Create a token for this client.
+ $user_id = $this->factory->user->create();
+ Token::create( $user_id, $client_id, array( Scope::READ ) );
+
+ // Delete the client.
+ $delete_result = Client::delete( $client_id );
+ $this->assertTrue( $delete_result );
+
+ // Client should no longer exist.
+ $get_result = Client::get( $client_id );
+ $this->assertInstanceOf( \WP_Error::class, $get_result );
+ }
+
+ /**
+ * Test delete method with non-existent client.
+ *
+ * @covers ::delete
+ */
+ public function test_delete_nonexistent() {
+ $result = Client::delete( 'nonexistent-client-id' );
+ $this->assertFalse( $result );
+ }
+
+ /**
+ * Test get_description method.
+ *
+ * @covers ::get_description
+ */
+ public function test_get_description() {
+ $result = $this->create_client(
+ array(
+ 'name' => 'Test Client',
+ 'redirect_uris' => array( 'https://example.com/callback' ),
+ 'description' => 'Test client description',
+ )
+ );
+
+ $client = Client::get( $result['client_id'] );
+
+ $this->assertEquals( 'Test client description', $client->get_description() );
+ }
+
+ /**
+ * Test get_client_id method.
+ *
+ * @covers ::get_client_id
+ */
+ public function test_get_client_id() {
+ $result = $this->create_client(
+ array(
+ 'name' => 'Test Client',
+ 'redirect_uris' => array( 'https://example.com/callback' ),
+ )
+ );
+
+ $client = Client::get( $result['client_id'] );
+
+ $this->assertEquals( $result['client_id'], $client->get_client_id() );
+ }
+}
diff --git a/tests/phpunit/tests/includes/oauth/class-test-scope.php b/tests/phpunit/tests/includes/oauth/class-test-scope.php
new file mode 100644
index 0000000000..3a97f4e2b6
--- /dev/null
+++ b/tests/phpunit/tests/includes/oauth/class-test-scope.php
@@ -0,0 +1,303 @@
+assertEquals( 'read', Scope::READ );
+ $this->assertEquals( 'write', Scope::WRITE );
+ $this->assertEquals( 'follow', Scope::FOLLOW );
+ $this->assertEquals( 'push', Scope::PUSH );
+ $this->assertEquals( 'profile', Scope::PROFILE );
+ }
+
+ /**
+ * Test ALL constant contains all scopes.
+ *
+ * @covers ::ALL
+ */
+ public function test_all_scopes_constant() {
+ $this->assertContains( Scope::READ, Scope::ALL );
+ $this->assertContains( Scope::WRITE, Scope::ALL );
+ $this->assertContains( Scope::FOLLOW, Scope::ALL );
+ $this->assertContains( Scope::PUSH, Scope::ALL );
+ $this->assertContains( Scope::PROFILE, Scope::ALL );
+ $this->assertCount( 5, Scope::ALL );
+ }
+
+ /**
+ * Test parse method with space-separated string.
+ *
+ * @covers ::parse
+ */
+ public function test_parse_space_separated() {
+ $result = Scope::parse( 'read write follow' );
+ $this->assertEquals( array( 'read', 'write', 'follow' ), $result );
+ }
+
+ /**
+ * Test parse method with single scope.
+ *
+ * @covers ::parse
+ */
+ public function test_parse_single_scope() {
+ $result = Scope::parse( 'read' );
+ $this->assertEquals( array( 'read' ), $result );
+ }
+
+ /**
+ * Test parse method with empty string.
+ *
+ * @covers ::parse
+ */
+ public function test_parse_empty_string() {
+ $result = Scope::parse( '' );
+ $this->assertEquals( array(), $result );
+ }
+
+ /**
+ * Test parse method with null.
+ *
+ * @covers ::parse
+ */
+ public function test_parse_null() {
+ $result = Scope::parse( null );
+ $this->assertEquals( array(), $result );
+ }
+
+ /**
+ * Test parse method with extra whitespace.
+ *
+ * @covers ::parse
+ */
+ public function test_parse_extra_whitespace() {
+ $result = Scope::parse( ' read write ' );
+ $this->assertEquals( array( 'read', 'write' ), $result );
+ }
+
+ /**
+ * Test validate method with valid scopes array.
+ *
+ * @covers ::validate
+ */
+ public function test_validate_valid_array() {
+ $result = Scope::validate( array( 'read', 'write' ) );
+ $this->assertEquals( array( 'read', 'write' ), $result );
+ }
+
+ /**
+ * Test validate method with string input.
+ *
+ * @covers ::validate
+ */
+ public function test_validate_string_input() {
+ $result = Scope::validate( 'read write follow' );
+ $this->assertEquals( array( 'read', 'write', 'follow' ), $result );
+ }
+
+ /**
+ * Test validate method filters out invalid scopes.
+ *
+ * @covers ::validate
+ */
+ public function test_validate_filters_invalid() {
+ $result = Scope::validate( array( 'read', 'invalid', 'write' ) );
+ $this->assertEquals( array( 'read', 'write' ), $result );
+ }
+
+ /**
+ * Test validate method returns defaults for empty input.
+ *
+ * @covers ::validate
+ */
+ public function test_validate_empty_returns_defaults() {
+ $result = Scope::validate( array() );
+ $this->assertEquals( Scope::DEFAULT_SCOPES, $result );
+ }
+
+ /**
+ * Test validate method returns defaults for all-invalid input.
+ *
+ * @covers ::validate
+ */
+ public function test_validate_all_invalid_returns_defaults() {
+ $result = Scope::validate( array( 'invalid1', 'invalid2' ) );
+ $this->assertEquals( Scope::DEFAULT_SCOPES, $result );
+ }
+
+ /**
+ * Test validate method with non-array input.
+ *
+ * @covers ::validate
+ */
+ public function test_validate_non_array_returns_defaults() {
+ $result = Scope::validate( 123 );
+ $this->assertEquals( Scope::DEFAULT_SCOPES, $result );
+ }
+
+ /**
+ * Test to_string method.
+ *
+ * @covers ::to_string
+ */
+ public function test_to_string() {
+ $result = Scope::to_string( array( 'read', 'write', 'follow' ) );
+ $this->assertEquals( 'read write follow', $result );
+ }
+
+ /**
+ * Test to_string method with empty array.
+ *
+ * @covers ::to_string
+ */
+ public function test_to_string_empty() {
+ $result = Scope::to_string( array() );
+ $this->assertEquals( '', $result );
+ }
+
+ /**
+ * Test to_string method with non-array.
+ *
+ * @covers ::to_string
+ */
+ public function test_to_string_non_array() {
+ $result = Scope::to_string( 'not an array' );
+ $this->assertEquals( '', $result );
+ }
+
+ /**
+ * Test is_valid method with valid scope.
+ *
+ * @covers ::is_valid
+ */
+ public function test_is_valid_true() {
+ $this->assertTrue( Scope::is_valid( 'read' ) );
+ $this->assertTrue( Scope::is_valid( 'write' ) );
+ $this->assertTrue( Scope::is_valid( 'follow' ) );
+ $this->assertTrue( Scope::is_valid( 'push' ) );
+ $this->assertTrue( Scope::is_valid( 'profile' ) );
+ }
+
+ /**
+ * Test is_valid method with invalid scope.
+ *
+ * @covers ::is_valid
+ */
+ public function test_is_valid_false() {
+ $this->assertFalse( Scope::is_valid( 'invalid' ) );
+ $this->assertFalse( Scope::is_valid( '' ) );
+ $this->assertFalse( Scope::is_valid( 'READ' ) ); // Case sensitive.
+ }
+
+ /**
+ * Test get_description method.
+ *
+ * @covers ::get_description
+ */
+ public function test_get_description() {
+ $this->assertNotEmpty( Scope::get_description( 'read' ) );
+ $this->assertNotEmpty( Scope::get_description( 'write' ) );
+ }
+
+ /**
+ * Test get_description method with invalid scope.
+ *
+ * @covers ::get_description
+ */
+ public function test_get_description_invalid() {
+ $this->assertEquals( '', Scope::get_description( 'invalid' ) );
+ }
+
+ /**
+ * Test get_all_with_descriptions method.
+ *
+ * @covers ::get_all_with_descriptions
+ */
+ public function test_get_all_with_descriptions() {
+ $result = Scope::get_all_with_descriptions();
+ $this->assertIsArray( $result );
+ $this->assertArrayHasKey( 'read', $result );
+ $this->assertArrayHasKey( 'write', $result );
+ $this->assertArrayHasKey( 'follow', $result );
+ $this->assertArrayHasKey( 'push', $result );
+ $this->assertArrayHasKey( 'profile', $result );
+ }
+
+ /**
+ * Test contains method with scope present.
+ *
+ * @covers ::contains
+ */
+ public function test_contains_true() {
+ $scopes = array( 'read', 'write' );
+ $this->assertTrue( Scope::contains( $scopes, 'read' ) );
+ $this->assertTrue( Scope::contains( $scopes, 'write' ) );
+ }
+
+ /**
+ * Test contains method with scope not present.
+ *
+ * @covers ::contains
+ */
+ public function test_contains_false() {
+ $scopes = array( 'read', 'write' );
+ $this->assertFalse( Scope::contains( $scopes, 'follow' ) );
+ }
+
+ /**
+ * Test contains method with non-array.
+ *
+ * @covers ::contains
+ */
+ public function test_contains_non_array() {
+ $this->assertFalse( Scope::contains( 'not an array', 'read' ) );
+ }
+
+ /**
+ * Test sanitize method with string.
+ *
+ * @covers ::sanitize
+ */
+ public function test_sanitize_string() {
+ $result = Scope::sanitize( 'read write invalid' );
+ $this->assertEquals( array( 'read', 'write' ), $result );
+ }
+
+ /**
+ * Test sanitize method with array.
+ *
+ * @covers ::sanitize
+ */
+ public function test_sanitize_array() {
+ $result = Scope::sanitize( array( 'read', 'invalid', 'write' ) );
+ $this->assertEquals( array( 'read', 'write' ), $result );
+ }
+
+ /**
+ * Test sanitize method with non-array/non-string.
+ *
+ * @covers ::sanitize
+ */
+ public function test_sanitize_invalid_type() {
+ $result = Scope::sanitize( 123 );
+ $this->assertEquals( array(), $result );
+ }
+}
diff --git a/tests/phpunit/tests/includes/oauth/class-test-token.php b/tests/phpunit/tests/includes/oauth/class-test-token.php
new file mode 100644
index 0000000000..7428872d4d
--- /dev/null
+++ b/tests/phpunit/tests/includes/oauth/class-test-token.php
@@ -0,0 +1,353 @@
+user_id = $this->factory->user->create(
+ array(
+ 'role' => 'editor',
+ )
+ );
+
+ // Create a test client.
+ $client_result = Client::register(
+ array(
+ 'name' => 'Test Client',
+ 'redirect_uris' => array( 'https://example.com/callback' ),
+ )
+ );
+ $this->client_id = $client_result['client_id'];
+ }
+
+ /**
+ * Tear down the test.
+ */
+ public function tear_down() {
+ // Clean up clients and tokens.
+ if ( $this->client_id ) {
+ Client::delete( $this->client_id );
+ }
+
+ parent::tear_down();
+ }
+
+ /**
+ * Test generate_token produces a hex string.
+ *
+ * @covers ::generate_token
+ */
+ public function test_generate_token() {
+ $token = Token::generate_token();
+
+ // Default length is 32 bytes = 64 hex chars.
+ $this->assertEquals( 64, strlen( $token ) );
+ $this->assertMatchesRegularExpression( '/^[0-9a-f]+$/', $token );
+ }
+
+ /**
+ * Test generate_token with custom length.
+ *
+ * @covers ::generate_token
+ */
+ public function test_generate_token_custom_length() {
+ $token = Token::generate_token( 16 );
+ $this->assertEquals( 32, strlen( $token ) );
+ }
+
+ /**
+ * Test hash_token produces SHA-256 hash.
+ *
+ * @covers ::hash_token
+ */
+ public function test_hash_token() {
+ $token = 'test_token_value';
+ $hash = Token::hash_token( $token );
+
+ // SHA-256 produces 64 hex chars.
+ $this->assertEquals( 64, strlen( $hash ) );
+ $this->assertEquals( hash( 'sha256', $token ), $hash );
+ }
+
+ /**
+ * Test create method returns token data.
+ *
+ * @covers ::create
+ */
+ public function test_create() {
+ $scopes = array( Scope::READ, Scope::WRITE );
+ $result = Token::create( $this->user_id, $this->client_id, $scopes );
+
+ $this->assertIsArray( $result );
+ $this->assertArrayHasKey( 'access_token', $result );
+ $this->assertArrayHasKey( 'token_type', $result );
+ $this->assertArrayHasKey( 'expires_in', $result );
+ $this->assertArrayHasKey( 'refresh_token', $result );
+ $this->assertArrayHasKey( 'scope', $result );
+
+ $this->assertEquals( 'Bearer', $result['token_type'] );
+ $this->assertEquals( Token::DEFAULT_EXPIRATION, $result['expires_in'] );
+ $this->assertEquals( 'read write', $result['scope'] );
+ }
+
+ /**
+ * Test validate method with valid token.
+ *
+ * @covers ::validate
+ */
+ public function test_validate_valid_token() {
+ $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) );
+ $token = Token::validate( $result['access_token'] );
+
+ $this->assertInstanceOf( Token::class, $token );
+ $this->assertEquals( $this->user_id, $token->get_user_id() );
+ $this->assertEquals( $this->client_id, $token->get_client_id() );
+ }
+
+ /**
+ * Test validate method with invalid token.
+ *
+ * @covers ::validate
+ */
+ public function test_validate_invalid_token() {
+ $result = Token::validate( 'invalid_token_value' );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'activitypub_invalid_token', $result->get_error_code() );
+ }
+
+ /**
+ * Test validate method with expired token.
+ *
+ * @covers ::validate
+ */
+ public function test_validate_expired_token() {
+ // Create a token that expires immediately.
+ $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ), 0 );
+
+ // Wait a moment for expiration.
+ sleep( 1 );
+
+ $validation = Token::validate( $result['access_token'] );
+
+ $this->assertInstanceOf( \WP_Error::class, $validation );
+ $this->assertEquals( 'activitypub_token_expired', $validation->get_error_code() );
+ }
+
+ /**
+ * Test token has_scope method.
+ *
+ * @covers ::has_scope
+ */
+ public function test_has_scope() {
+ $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ, Scope::WRITE ) );
+ $token = Token::validate( $result['access_token'] );
+
+ $this->assertTrue( $token->has_scope( Scope::READ ) );
+ $this->assertTrue( $token->has_scope( Scope::WRITE ) );
+ $this->assertFalse( $token->has_scope( Scope::FOLLOW ) );
+ }
+
+ /**
+ * Test token get_scopes method.
+ *
+ * @covers ::get_scopes
+ */
+ public function test_get_scopes() {
+ $scopes = array( Scope::READ, Scope::WRITE );
+ $result = Token::create( $this->user_id, $this->client_id, $scopes );
+ $token = Token::validate( $result['access_token'] );
+
+ $this->assertEquals( $scopes, $token->get_scopes() );
+ }
+
+ /**
+ * Test token get_expires_at method.
+ *
+ * @covers ::get_expires_at
+ */
+ public function test_get_expires_at() {
+ $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) );
+ $token = Token::validate( $result['access_token'] );
+
+ $expires_at = $token->get_expires_at();
+ $this->assertIsInt( $expires_at );
+ $this->assertGreaterThan( time(), $expires_at );
+ }
+
+ /**
+ * Test token is_expired method.
+ *
+ * @covers ::is_expired
+ */
+ public function test_is_expired() {
+ $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) );
+ $token = Token::validate( $result['access_token'] );
+
+ $this->assertFalse( $token->is_expired() );
+ }
+
+ /**
+ * Test refresh method.
+ *
+ * @covers ::refresh
+ */
+ public function test_refresh() {
+ $original = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) );
+ $result = Token::refresh( $original['refresh_token'], $this->client_id );
+
+ $this->assertIsArray( $result );
+ $this->assertArrayHasKey( 'access_token', $result );
+ $this->assertArrayHasKey( 'refresh_token', $result );
+
+ // New tokens should be different.
+ $this->assertNotEquals( $original['access_token'], $result['access_token'] );
+ $this->assertNotEquals( $original['refresh_token'], $result['refresh_token'] );
+
+ // Old token should be revoked.
+ $old_validation = Token::validate( $original['access_token'] );
+ $this->assertInstanceOf( \WP_Error::class, $old_validation );
+ }
+
+ /**
+ * Test refresh method with invalid refresh token.
+ *
+ * @covers ::refresh
+ */
+ public function test_refresh_invalid_token() {
+ $result = Token::refresh( 'invalid_refresh_token', $this->client_id );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'activitypub_invalid_refresh_token', $result->get_error_code() );
+ }
+
+ /**
+ * Test refresh method with wrong client ID.
+ *
+ * @covers ::refresh
+ */
+ public function test_refresh_wrong_client() {
+ $original = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) );
+ $result = Token::refresh( $original['refresh_token'], 'wrong_client_id' );
+
+ $this->assertInstanceOf( \WP_Error::class, $result );
+ $this->assertEquals( 'activitypub_invalid_refresh_token', $result->get_error_code() );
+ }
+
+ /**
+ * Test revoke method with access token.
+ *
+ * @covers ::revoke
+ */
+ public function test_revoke_access_token() {
+ $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) );
+
+ $revoke_result = Token::revoke( $result['access_token'] );
+ $this->assertTrue( $revoke_result );
+
+ // Token should no longer validate.
+ $validation = Token::validate( $result['access_token'] );
+ $this->assertInstanceOf( \WP_Error::class, $validation );
+ }
+
+ /**
+ * Test revoke method with refresh token.
+ *
+ * @covers ::revoke
+ */
+ public function test_revoke_refresh_token() {
+ $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) );
+
+ $revoke_result = Token::revoke( $result['refresh_token'] );
+ $this->assertTrue( $revoke_result );
+
+ // Refresh should fail.
+ $refresh_result = Token::refresh( $result['refresh_token'], $this->client_id );
+ $this->assertInstanceOf( \WP_Error::class, $refresh_result );
+ }
+
+ /**
+ * Test revoke method with non-existent token.
+ *
+ * @covers ::revoke
+ */
+ public function test_revoke_nonexistent_token() {
+ // Per RFC 7009, revoking a non-existent token should succeed.
+ $result = Token::revoke( 'nonexistent_token' );
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * Test revoke_all_for_user method.
+ *
+ * @covers ::revoke_all_for_user
+ */
+ public function test_revoke_all_for_user() {
+ // Create multiple tokens.
+ $token1 = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) );
+ $token2 = Token::create( $this->user_id, $this->client_id, array( Scope::WRITE ) );
+ $token3 = Token::create( $this->user_id, $this->client_id, array( Scope::FOLLOW ) );
+
+ $count = Token::revoke_all_for_user( $this->user_id );
+ $this->assertEquals( 3, $count );
+
+ // All tokens should be revoked.
+ $this->assertInstanceOf( \WP_Error::class, Token::validate( $token1['access_token'] ) );
+ $this->assertInstanceOf( \WP_Error::class, Token::validate( $token2['access_token'] ) );
+ $this->assertInstanceOf( \WP_Error::class, Token::validate( $token3['access_token'] ) );
+ }
+
+ /**
+ * Test cleanup_expired method.
+ *
+ * @covers ::cleanup_expired
+ */
+ public function test_cleanup_expired() {
+ // Create a token that expires immediately.
+ Token::create( $this->user_id, $this->client_id, array( Scope::READ ), 0 );
+
+ // Wait for expiration plus grace period buffer (normally 1 day, but we can't wait that long).
+ // For this test, we'll just verify the method runs without error.
+ $count = Token::cleanup_expired();
+ $this->assertIsInt( $count );
+ }
+}
From d686406832c2b4e702bfb673fb2910bb9f63a262 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 1 Feb 2026 09:31:19 +0100
Subject: [PATCH 005/105] Fix method signature compatibility with parent class
Remove type hint from get_items_permissions_check() to match the
parent WP_REST_Controller class signature, which doesn't use type hints.
---
includes/rest/class-inbox-controller.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php
index 652d2d5fcc..2c3bc5cecd 100644
--- a/includes/rest/class-inbox-controller.php
+++ b/includes/rest/class-inbox-controller.php
@@ -216,7 +216,7 @@ public function validate_user_id( $user_id ) {
* @param \WP_REST_Request $request Full details about the request.
* @return bool|\WP_Error True if authorized, WP_Error otherwise.
*/
- public function get_items_permissions_check( \WP_REST_Request $request ) {
+ public function get_items_permissions_check( $request ) {
// Check if C2S is enabled.
if ( ! OAuth_Server::is_c2s_enabled() ) {
return new \WP_Error(
From c428161b361cdcf32e03d5d9349532b05f2890c3 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 1 Feb 2026 09:34:30 +0100
Subject: [PATCH 006/105] Fix create_item_permissions_check method signature
Remove type hint from create_item_permissions_check() to match the
parent WP_REST_Controller class signature.
---
includes/rest/class-outbox-controller.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php
index 22644d0223..c876fa58c7 100644
--- a/includes/rest/class-outbox-controller.php
+++ b/includes/rest/class-outbox-controller.php
@@ -334,7 +334,7 @@ public function overload_total_items( $response, $request ) {
* @param \WP_REST_Request $request Full details about the request.
* @return bool|\WP_Error True if authorized, WP_Error otherwise.
*/
- public function create_item_permissions_check( \WP_REST_Request $request ) {
+ public function create_item_permissions_check( $request ) {
// Check if C2S is enabled.
if ( ! OAuth_Server::is_c2s_enabled() ) {
return new \WP_Error(
From 8a584a1138d9d2705a11770f80edd620a05a58af Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 1 Feb 2026 09:38:17 +0100
Subject: [PATCH 007/105] Fix create_item method signature compatibility
Remove type hint from create_item() to match the parent
WP_REST_Controller class signature.
---
includes/rest/class-outbox-controller.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php
index c876fa58c7..b5d7b65f90 100644
--- a/includes/rest/class-outbox-controller.php
+++ b/includes/rest/class-outbox-controller.php
@@ -375,7 +375,7 @@ public function create_item_permissions_check( $request ) {
* @param \WP_REST_Request $request Full details about the request.
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error on failure.
*/
- public function create_item( \WP_REST_Request $request ) {
+ public function create_item( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = Actors::get_by_id( $user_id );
$data = $request->get_json_params();
From 7906d5120edf306e4e717f8dc7a42a85be50ace1 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 1 Feb 2026 09:42:20 +0100
Subject: [PATCH 008/105] Remove invalid @covers annotations for constants
Constants cannot be covered by PHPUnit, only methods can.
---
tests/phpunit/tests/includes/oauth/class-test-scope.php | 4 ----
1 file changed, 4 deletions(-)
diff --git a/tests/phpunit/tests/includes/oauth/class-test-scope.php b/tests/phpunit/tests/includes/oauth/class-test-scope.php
index 3a97f4e2b6..6a4111951a 100644
--- a/tests/phpunit/tests/includes/oauth/class-test-scope.php
+++ b/tests/phpunit/tests/includes/oauth/class-test-scope.php
@@ -18,8 +18,6 @@ class Test_Scope extends \WP_UnitTestCase {
/**
* Test that all scope constants are defined.
- *
- * @covers ::ALL
*/
public function test_scope_constants_defined() {
$this->assertEquals( 'read', Scope::READ );
@@ -31,8 +29,6 @@ public function test_scope_constants_defined() {
/**
* Test ALL constant contains all scopes.
- *
- * @covers ::ALL
*/
public function test_all_scopes_constant() {
$this->assertContains( Scope::READ, Scope::ALL );
From c498877a911c398c1589d473670a05dd687423f5 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 1 Feb 2026 09:48:06 +0100
Subject: [PATCH 009/105] Add actor ownership validation for C2S outbox
Validate that submitted activities have actor/attributedTo fields
matching the authenticated user. This prevents clients from submitting
activities with mismatched actor data.
Checks:
- activity.actor must match authenticated user (if present)
- object.attributedTo must match authenticated user (if present)
---
includes/rest/class-outbox-controller.php | 54 +++++++++++++++++++++++
1 file changed, 54 insertions(+)
diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php
index b5d7b65f90..cf84aa98f0 100644
--- a/includes/rest/class-outbox-controller.php
+++ b/includes/rest/class-outbox-controller.php
@@ -388,6 +388,12 @@ public function create_item( $request ) {
);
}
+ // Validate ownership - ensure submitted actor matches authenticated user.
+ $ownership_validation = $this->validate_ownership( $data, $user );
+ if ( \is_wp_error( $ownership_validation ) ) {
+ return $ownership_validation;
+ }
+
// Determine if this is an Activity or a bare Object.
$type = $data['type'] ?? '';
$is_activity = in_array( $type, Activity::TYPES, true );
@@ -500,6 +506,54 @@ private function wrap_in_create( $object_data, $user ) {
);
}
+ /**
+ * Validate that activity actor matches the authenticated user.
+ *
+ * Ensures clients cannot submit activities with mismatched actor data.
+ *
+ * @param array $data The activity or object data.
+ * @param \Activitypub\Model\User|null $user The authenticated user.
+ * @return true|\WP_Error True if valid, WP_Error otherwise.
+ */
+ private function validate_ownership( $data, $user ) {
+ if ( ! $user ) {
+ return new \WP_Error(
+ 'activitypub_invalid_user',
+ \__( 'Invalid user.', 'activitypub' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ $user_actor_id = $user->get_id();
+
+ // Check activity actor if present.
+ if ( ! empty( $data['actor'] ) ) {
+ $actor_id = object_to_uri( $data['actor'] );
+ if ( $actor_id && $actor_id !== $user_actor_id ) {
+ return new \WP_Error(
+ 'activitypub_actor_mismatch',
+ \__( 'Activity actor does not match authenticated user.', 'activitypub' ),
+ array( 'status' => 403 )
+ );
+ }
+ }
+
+ // Check object.attributedTo if present.
+ $object = $data['object'] ?? $data;
+ if ( is_array( $object ) && ! empty( $object['attributedTo'] ) ) {
+ $attributed_to = object_to_uri( $object['attributedTo'] );
+ if ( $attributed_to && $attributed_to !== $user_actor_id ) {
+ return new \WP_Error(
+ 'activitypub_attribution_mismatch',
+ \__( 'Object attributedTo does not match authenticated user.', 'activitypub' ),
+ array( 'status' => 403 )
+ );
+ }
+ }
+
+ return true;
+ }
+
/**
* Determine content visibility from activity addressing.
*
From 5155bf479ffc03f3c120fdf4011e49a6585cc534 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 1 Feb 2026 15:02:43 +0100
Subject: [PATCH 010/105] Migrate OAuth storage to transients and user meta
- Authorization codes now use WordPress transients (auto-expire after 10 min)
- Tokens now use user meta instead of CPT (efficient per-user lookup)
- Keep only Client CPT for persistent client registration
- Add token introspection endpoint (RFC 7662)
- Add revoke_for_client() method for cleanup when deleting clients
- Add OAuth consent form template
- Fix linting issues in Server class
- Update tests for new error codes
---
activitypub.php | 7 +-
includes/class-post-types.php | 183 +-----
includes/oauth/class-authorization-code.php | 196 +++---
includes/oauth/class-client.php | 262 +++++++-
includes/oauth/class-server.php | 162 +++++
includes/oauth/class-token.php | 570 ++++++++++++------
includes/rest/class-oauth-controller.php | 87 ++-
includes/rest/class-outbox-controller.php | 40 ++
templates/oauth-authorize.php | 112 ++++
.../oauth/class-test-authorization-code.php | 2 +-
.../tests/includes/oauth/class-test-token.php | 2 +-
11 files changed, 1080 insertions(+), 543 deletions(-)
create mode 100644 templates/oauth-authorize.php
diff --git a/activitypub.php b/activitypub.php
index 91c129c596..2f722ae9d2 100644
--- a/activitypub.php
+++ b/activitypub.php
@@ -70,8 +70,7 @@ function rest_init() {
( new Rest\Nodeinfo_Controller() )->register_routes();
}
- // Load OAuth endpoints if C2S is enabled.
- OAuth\Server::init();
+ // Load OAuth REST endpoints.
( new Rest\OAuth_Controller() )->register_routes();
}
\add_action( 'rest_api_init', __NAMESPACE__ . '\rest_init' );
@@ -100,6 +99,7 @@ function plugin_init() {
\add_action( 'init', array( __NAMESPACE__ . '\Scheduler', 'init' ), 0 );
\add_action( 'init', array( __NAMESPACE__ . '\Search', 'init' ) );
\add_action( 'init', array( __NAMESPACE__ . '\Signature', 'init' ) );
+ \add_action( 'init', array( __NAMESPACE__ . '\OAuth\Server', 'init' ) );
if ( site_supports_blocks() ) {
\add_action( 'init', array( __NAMESPACE__ . '\Blocks', 'init' ) );
@@ -177,3 +177,6 @@ function activation_redirect( $plugin ) {
)
);
}
+
+// Register OAuth login form handler early (before wp-login.php processes).
+\add_action( 'login_form_activitypub_authorize', array( __NAMESPACE__ . '\OAuth\Server', 'login_form_authorize' ) );
diff --git a/includes/class-post-types.php b/includes/class-post-types.php
index 52c375f9ab..9a16ccb678 100644
--- a/includes/class-post-types.php
+++ b/includes/class-post-types.php
@@ -15,10 +15,8 @@
use Activitypub\Collection\Outbox;
use Activitypub\Collection\Posts;
use Activitypub\Collection\Remote_Actors;
-use Activitypub\OAuth\Authorization_Code;
use Activitypub\OAuth\Client;
use Activitypub\OAuth\Scope;
-use Activitypub\OAuth\Token;
/**
* Post Types class.
@@ -464,85 +462,10 @@ public static function register_extra_fields_post_types() {
/**
* Register OAuth 2.0 post types for C2S support.
*
- * Registers post types for OAuth tokens, clients, and authorization codes.
+ * Registers post type for OAuth clients.
+ * Note: Tokens are stored in user meta and authorization codes in transients.
*/
public static function register_oauth_post_types() {
- // OAuth Tokens post type.
- \register_post_type(
- Token::POST_TYPE,
- array(
- 'labels' => array(
- 'name' => \_x( 'OAuth Tokens', 'post_type plural name', 'activitypub' ),
- 'singular_name' => \_x( 'OAuth Token', 'post_type single name', 'activitypub' ),
- ),
- 'public' => false,
- 'show_in_rest' => false,
- 'hierarchical' => false,
- 'rewrite' => false,
- 'query_var' => false,
- 'delete_with_user' => true,
- 'can_export' => false,
- 'supports' => array( 'author', 'custom-fields' ),
- 'exclude_from_search' => true,
- )
- );
-
- // OAuth Token meta.
- \register_post_meta(
- Token::POST_TYPE,
- '_activitypub_access_token_hash',
- array(
- 'type' => 'string',
- 'single' => true,
- 'description' => 'SHA-256 hash of the access token.',
- 'sanitize_callback' => 'sanitize_text_field',
- )
- );
-
- \register_post_meta(
- Token::POST_TYPE,
- '_activitypub_refresh_token_hash',
- array(
- 'type' => 'string',
- 'single' => true,
- 'description' => 'SHA-256 hash of the refresh token.',
- 'sanitize_callback' => 'sanitize_text_field',
- )
- );
-
- \register_post_meta(
- Token::POST_TYPE,
- '_activitypub_client_id',
- array(
- 'type' => 'string',
- 'single' => true,
- 'description' => 'The OAuth client ID associated with this token.',
- 'sanitize_callback' => 'sanitize_text_field',
- )
- );
-
- \register_post_meta(
- Token::POST_TYPE,
- '_activitypub_scopes',
- array(
- 'type' => 'array',
- 'single' => true,
- 'description' => 'Granted OAuth scopes.',
- 'sanitize_callback' => array( Scope::class, 'sanitize' ),
- )
- );
-
- \register_post_meta(
- Token::POST_TYPE,
- '_activitypub_expires_at',
- array(
- 'type' => 'integer',
- 'single' => true,
- 'description' => 'Unix timestamp when the access token expires.',
- 'sanitize_callback' => 'absint',
- )
- );
-
// OAuth Clients post type.
\register_post_type(
Client::POST_TYPE,
@@ -624,108 +547,6 @@ public static function register_oauth_post_types() {
'default' => true,
)
);
-
- // OAuth Authorization Codes post type.
- \register_post_type(
- Authorization_Code::POST_TYPE,
- array(
- 'labels' => array(
- 'name' => \_x( 'OAuth Codes', 'post_type plural name', 'activitypub' ),
- 'singular_name' => \_x( 'OAuth Code', 'post_type single name', 'activitypub' ),
- ),
- 'public' => false,
- 'show_in_rest' => false,
- 'hierarchical' => false,
- 'rewrite' => false,
- 'query_var' => false,
- 'delete_with_user' => true,
- 'can_export' => false,
- 'supports' => array( 'author', 'custom-fields' ),
- 'exclude_from_search' => true,
- )
- );
-
- // OAuth Authorization Code meta.
- \register_post_meta(
- Authorization_Code::POST_TYPE,
- '_activitypub_code_hash',
- array(
- 'type' => 'string',
- 'single' => true,
- 'description' => 'SHA-256 hash of the authorization code.',
- 'sanitize_callback' => 'sanitize_text_field',
- )
- );
-
- \register_post_meta(
- Authorization_Code::POST_TYPE,
- '_activitypub_client_id',
- array(
- 'type' => 'string',
- 'single' => true,
- 'description' => 'The OAuth client ID that requested this code.',
- 'sanitize_callback' => 'sanitize_text_field',
- )
- );
-
- \register_post_meta(
- Authorization_Code::POST_TYPE,
- '_activitypub_redirect_uri',
- array(
- 'type' => 'string',
- 'single' => true,
- 'description' => 'The redirect URI used for this authorization.',
- 'sanitize_callback' => 'sanitize_url',
- )
- );
-
- \register_post_meta(
- Authorization_Code::POST_TYPE,
- '_activitypub_scopes',
- array(
- 'type' => 'array',
- 'single' => true,
- 'description' => 'Requested OAuth scopes.',
- 'sanitize_callback' => array( Scope::class, 'sanitize' ),
- )
- );
-
- \register_post_meta(
- Authorization_Code::POST_TYPE,
- '_activitypub_code_challenge',
- array(
- 'type' => 'string',
- 'single' => true,
- 'description' => 'PKCE code challenge.',
- 'sanitize_callback' => 'sanitize_text_field',
- )
- );
-
- \register_post_meta(
- Authorization_Code::POST_TYPE,
- '_activitypub_code_challenge_method',
- array(
- 'type' => 'string',
- 'single' => true,
- 'description' => 'PKCE code challenge method (S256 or plain).',
- 'sanitize_callback' => static function ( $value ) {
- $allowed = array( 'S256', 'plain' );
- return in_array( $value, $allowed, true ) ? $value : 'S256';
- },
- 'default' => 'S256',
- )
- );
-
- \register_post_meta(
- Authorization_Code::POST_TYPE,
- '_activitypub_expires_at',
- array(
- 'type' => 'integer',
- 'single' => true,
- 'description' => 'Unix timestamp when the authorization code expires (10 minutes).',
- 'sanitize_callback' => 'absint',
- )
- );
}
/**
diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php
index da927eee67..829534af20 100644
--- a/includes/oauth/class-authorization-code.php
+++ b/includes/oauth/class-authorization-code.php
@@ -10,45 +10,20 @@
/**
* Authorization_Code class for managing OAuth 2.0 authorization codes.
*
- * Authorization codes are short-lived (10 minutes) and support PKCE.
+ * Authorization codes are short-lived (10 minutes) and stored as transients.
+ * This is more efficient than CPT for temporary data.
*/
class Authorization_Code {
/**
- * Post type for OAuth authorization codes.
+ * Transient prefix for authorization codes.
*/
- const POST_TYPE = 'ap_oauth_code';
-
- /**
- * Post status for pending (unused) codes.
- */
- const STATUS_PENDING = 'pending';
-
- /**
- * Post status for used codes.
- */
- const STATUS_USED = 'draft';
+ const TRANSIENT_PREFIX = 'activitypub_oauth_code_';
/**
* Authorization code expiration in seconds (10 minutes).
*/
const EXPIRATION = 600;
- /**
- * The post ID of the authorization code.
- *
- * @var int
- */
- private $post_id;
-
- /**
- * Constructor.
- *
- * @param int $post_id The post ID of the authorization code.
- */
- public function __construct( $post_id ) {
- $this->post_id = $post_id;
- }
-
/**
* Create a new authorization code.
*
@@ -88,34 +63,33 @@ public static function create(
// Generate the code.
$code = self::generate_code();
+ $code_hash = self::hash_code( $code );
$expires_at = time() + self::EXPIRATION;
- // Create the authorization code post.
- $post_id = \wp_insert_post(
- array(
- 'post_type' => self::POST_TYPE,
- 'post_status' => self::STATUS_PENDING,
- 'post_author' => $user_id,
- 'post_title' => sprintf(
- /* translators: %1$s: client ID */
- \__( 'Auth code for %1$s', 'activitypub' ),
- $client_id
- ),
- 'meta_input' => array(
- '_activitypub_code_hash' => Token::hash_token( $code ),
- '_activitypub_client_id' => $client_id,
- '_activitypub_redirect_uri' => $redirect_uri,
- '_activitypub_scopes' => $filtered_scopes,
- '_activitypub_code_challenge' => $code_challenge,
- '_activitypub_code_challenge_method' => $code_challenge_method,
- '_activitypub_expires_at' => $expires_at,
- ),
- ),
- true
+ // 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 ( \is_wp_error( $post_id ) ) {
- return $post_id;
+ if ( ! $stored ) {
+ return new \WP_Error(
+ 'activitypub_code_storage_failed',
+ \__( 'Failed to store authorization code.', 'activitypub' ),
+ array( 'status' => 500 )
+ );
}
return $code;
@@ -131,49 +105,23 @@ public static function create(
* @return array|\WP_Error Token data or error.
*/
public static function exchange( $code, $client_id, $redirect_uri, $code_verifier ) {
- $hash = Token::hash_token( $code );
+ $code_hash = self::hash_code( $code );
+ $transient = self::TRANSIENT_PREFIX . $code_hash;
+ $code_data = \get_transient( $transient );
- // Find the authorization code.
- $posts = \get_posts(
- array(
- 'post_type' => self::POST_TYPE,
- 'post_status' => self::STATUS_PENDING,
- 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
- 'relation' => 'AND',
- array(
- 'key' => '_activitypub_code_hash',
- 'value' => $hash,
- ),
- array(
- 'key' => '_activitypub_client_id',
- 'value' => $client_id,
- ),
- ),
- 'numberposts' => 1,
- )
- );
-
- if ( empty( $posts ) ) {
+ if ( false === $code_data ) {
return new \WP_Error(
'activitypub_invalid_code',
- \__( 'Invalid authorization code.', 'activitypub' ),
+ \__( 'Invalid or expired authorization code.', 'activitypub' ),
array( 'status' => 400 )
);
}
- $post = $posts[0];
-
- // Check expiration.
- $expires_at = (int) \get_post_meta( $post->ID, '_activitypub_expires_at', true );
- if ( $expires_at < time() ) {
- // Mark as used to prevent further attempts.
- \wp_update_post(
- array(
- 'ID' => $post->ID,
- 'post_status' => self::STATUS_USED,
- )
- );
+ // 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' ),
@@ -181,9 +129,17 @@ public static function exchange( $code, $client_id, $redirect_uri, $code_verifie
);
}
+ // 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.
- $stored_redirect_uri = \get_post_meta( $post->ID, '_activitypub_redirect_uri', true );
- if ( $redirect_uri !== $stored_redirect_uri ) {
+ if ( $code_data['redirect_uri'] !== $redirect_uri ) {
return new \WP_Error(
'activitypub_redirect_uri_mismatch',
\__( 'Redirect URI does not match.', 'activitypub' ),
@@ -192,8 +148,8 @@ public static function exchange( $code, $client_id, $redirect_uri, $code_verifie
}
// Verify PKCE.
- $code_challenge = \get_post_meta( $post->ID, '_activitypub_code_challenge', true );
- $code_challenge_method = \get_post_meta( $post->ID, '_activitypub_code_challenge_method', true ) ?: 'S256';
+ $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(
@@ -203,20 +159,12 @@ public static function exchange( $code, $client_id, $redirect_uri, $code_verifie
);
}
- // Mark the code as used (single use).
- \wp_update_post(
- array(
- 'ID' => $post->ID,
- 'post_status' => self::STATUS_USED,
- )
- );
-
- // Get the user and scopes.
- $user_id = $post->post_author;
- $scopes = \get_post_meta( $post->ID, '_activitypub_scopes', true );
-
// Create and return the tokens.
- return Token::create( $user_id, $client_id, $scopes );
+ return Token::create(
+ $code_data['user_id'],
+ $client_id,
+ $code_data['scopes']
+ );
}
/**
@@ -260,12 +208,23 @@ public static function compute_code_challenge( $code_verifier ) {
* @return string The authorization code.
*/
public static function generate_code() {
- return Token::generate_token( 32 );
+ 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.
*
+ * Note: Transients auto-expire, but this cleans up any orphaned ones.
* Should be called periodically via cron.
*
* @return int Number of codes deleted.
@@ -273,25 +232,18 @@ public static function generate_code() {
public static function cleanup() {
global $wpdb;
- $expired_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ // Clean up expired transients with our prefix.
+ // Transients should auto-expire, but this catches edge cases.
+ $count = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prepare(
- "SELECT p.ID FROM {$wpdb->posts} p
- INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
- WHERE p.post_type = %s
- AND pm.meta_key = '_activitypub_expires_at'
- AND pm.meta_value < %d",
- self::POST_TYPE,
- time()
+ "DELETE FROM {$wpdb->options}
+ WHERE option_name LIKE %s
+ AND option_name LIKE %s",
+ $wpdb->esc_like( '_transient_' . self::TRANSIENT_PREFIX ) . '%',
+ $wpdb->esc_like( '_transient_timeout_' . self::TRANSIENT_PREFIX ) . '%'
)
);
- $count = 0;
- foreach ( $expired_ids as $post_id ) {
- if ( \wp_delete_post( $post_id, true ) ) {
- ++$count;
- }
- }
-
- return $count;
+ return $count ? (int) ( $count / 2 ) : 0; // Each transient has 2 rows.
}
}
diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php
index c09628727a..3625c3de9b 100644
--- a/includes/oauth/class-client.php
+++ b/includes/oauth/class-client.php
@@ -125,6 +125,9 @@ public static function register( $data ) {
/**
* 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.
*/
@@ -141,15 +144,198 @@ public static function get( $client_id ) {
);
// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
- if ( empty( $posts ) ) {
+ if ( ! empty( $posts ) ) {
+ return new self( $posts[0]->ID );
+ }
+
+ // If client_id is a URL, try auto-discovery.
+ if ( filter_var( $client_id, FILTER_VALIDATE_URL ) ) {
+ return self::discover_and_register( $client_id );
+ }
+
+ return new \WP_Error(
+ 'activitypub_client_not_found',
+ \__( 'OAuth client not found.', 'activitypub' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ /**
+ * Discover client metadata from URL and auto-register.
+ *
+ * Fetches the Client ID Metadata Document (CIMD) from the client_id URL.
+ *
+ * @param string $client_id The client ID URL.
+ * @return Client|\WP_Error The client or error.
+ */
+ private static function discover_and_register( $client_id ) {
+ $metadata = self::fetch_client_metadata( $client_id );
+
+ if ( \is_wp_error( $metadata ) ) {
+ return $metadata;
+ }
+
+ // Validate client_id matches.
+ if ( ! empty( $metadata['client_id'] ) && $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'] ) ) {
+ $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( 'sanitize_url', $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 ) {
+ $response = \wp_safe_remote_get(
+ $url,
+ array(
+ 'timeout' => 10,
+ 'headers' => array(
+ 'Accept' => 'application/json, application/ld+json, application/activity+json',
+ ),
+ 'redirection' => 3,
+ )
+ );
+
+ if ( \is_wp_error( $response ) ) {
+ return new \WP_Error(
+ 'activitypub_client_fetch_failed',
+ \__( 'Failed to fetch client metadata.', 'activitypub' ),
+ array( 'status' => 502 )
+ );
+ }
+
+ $code = \wp_remote_retrieve_response_code( $response );
+ if ( 200 !== $code ) {
return new \WP_Error(
- 'activitypub_client_not_found',
- \__( 'OAuth client not found.', 'activitypub' ),
- array( 'status' => 404 )
+ 'activitypub_client_fetch_failed',
+ /* translators: %d: HTTP status code */
+ sprintf( \__( 'Client metadata returned HTTP %d.', 'activitypub' ), $code ),
+ array( 'status' => 502 )
);
}
- return new self( $posts[0]->ID );
+ $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, $url );
+ }
+
+ /**
+ * Normalize client metadata from various formats to standard format.
+ *
+ * Supports:
+ * - CIMD (Client ID Metadata Document)
+ * - ActivityPub Application objects
+ *
+ * @param array $data The raw metadata.
+ * @param string $url The client ID URL.
+ * @return array Normalized metadata.
+ */
+ private static function normalize_client_metadata( $data, $url ) {
+ $metadata = array(
+ 'client_id' => $url,
+ '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'];
+ }
+
+ // ActivityPub Application format fields.
+ if ( ! empty( $data['type'] ) && 'Application' === $data['type'] ) {
+ if ( ! empty( $data['id'] ) ) {
+ $metadata['client_id'] = $data['id'];
+ }
+ if ( ! empty( $data['name'] ) ) {
+ $metadata['client_name'] = $data['name'];
+ }
+ // Handle redirectURI (singular) as used by ap CLI.
+ if ( ! empty( $data['redirectURI'] ) ) {
+ $metadata['redirect_uris'] = (array) $data['redirectURI'];
+ }
+ // Handle icon object.
+ if ( ! 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( $data['url'] ) ) {
+ $metadata['client_uri'] = is_array( $data['url'] ) ? $data['url'][0] : $data['url'];
+ }
+ }
+
+ return $metadata;
}
/**
@@ -184,14 +370,31 @@ public static function validate( $client_id, $client_secret = null ) {
/**
* Check if redirect URI is valid for this client.
*
+ * If explicit redirect_uris are registered, requires exact match.
+ * For auto-discovered clients without redirect_uris, uses same-origin policy.
+ *
* @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();
- // Exact match required.
- return in_array( $redirect_uri, $allowed_uris, true );
+ // If explicit redirect URIs are registered, require exact match.
+ if ( ! empty( $allowed_uris ) ) {
+ return in_array( $redirect_uri, $allowed_uris, true );
+ }
+
+ // For auto-discovered clients without redirect_uris, use same-origin policy.
+ // The redirect_uri must be on the same host as the client_id.
+ $client_id = $this->get_client_id();
+ if ( filter_var( $client_id, FILTER_VALIDATE_URL ) ) {
+ $client_host = \wp_parse_url( $client_id, PHP_URL_HOST );
+ $redirect_host = \wp_parse_url( $redirect_uri, PHP_URL_HOST );
+
+ return $client_host === $redirect_host;
+ }
+
+ return false;
}
/**
@@ -243,6 +446,33 @@ public function get_allowed_scopes() {
return is_array( $scopes ) ? $scopes : Scope::ALL;
}
+ /**
+ * 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 ) ?: '';
+ }
+
+ /**
+ * 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.
*
@@ -326,22 +556,8 @@ public static function delete( $client_id ) {
return false;
}
- // Delete all tokens for this client.
- // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Token cleanup by client ID is necessary.
- $tokens = \get_posts(
- array(
- 'post_type' => Token::POST_TYPE,
- 'meta_key' => '_activitypub_client_id',
- 'meta_value' => $client_id,
- 'numberposts' => -1,
- 'fields' => 'ids',
- )
- );
- // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
-
- foreach ( $tokens as $token_id ) {
- \wp_delete_post( $token_id, true );
- }
+ // Delete all tokens for this client (tokens are stored in user meta).
+ Token::revoke_for_client( $client_id );
// Delete the client.
return (bool) \wp_delete_post( $client->post_id, true );
diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php
index af933df0d9..c37422acf8 100644
--- a/includes/oauth/class-server.php
+++ b/includes/oauth/class-server.php
@@ -242,14 +242,176 @@ public static function get_metadata() {
'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' ),
+ 'introspection_endpoint_auth_methods_supported' => array( 'none' ),
'code_challenge_methods_supported' => array( 'S256', 'plain' ),
'service_documentation' => 'https://github.com/swicg/activitypub-api',
);
}
+
+ /**
+ * 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();
+ }
+
+ // Check if C2S is enabled.
+ if ( ! self::is_c2s_enabled() ) {
+ \wp_die(
+ \esc_html__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ),
+ \esc_html__( 'Authorization Error', 'activitypub' ),
+ array( 'response' => 403 )
+ );
+ }
+
+ $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.
+ $client_id = isset( $_GET['client_id'] ) ? \sanitize_text_field( \wp_unslash( $_GET['client_id'] ) ) : '';
+ $redirect_uri = isset( $_GET['redirect_uri'] ) ? \esc_url_raw( \wp_unslash( $_GET['redirect_uri'] ) ) : '';
+ $scope = isset( $_GET['scope'] ) ? \sanitize_text_field( \wp_unslash( $_GET['scope'] ) ) : '';
+ $state = isset( $_GET['state'] ) ? \sanitize_text_field( \wp_unslash( $_GET['state'] ) ) : '';
+ $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( $client_id );
+ if ( \is_wp_error( $client ) ) {
+ \wp_die(
+ \esc_html( $client->get_error_message() ),
+ \esc_html__( 'Authorization Error', 'activitypub' ),
+ array( 'response' => 404 )
+ );
+ }
+
+ // Validate redirect URI.
+ if ( ! $client->is_valid_redirect_uri( $redirect_uri ) ) {
+ \wp_die(
+ \esc_html__( 'Invalid redirect URI for this client.', 'activitypub' ),
+ \esc_html__( 'Authorization Error', 'activitypub' ),
+ array( 'response' => 400 )
+ );
+ }
+
+ // These variables are used in the template.
+ $current_user = \wp_get_current_user(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
+ $scopes = Scope::validate( Scope::parse( $scope ) ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
+ $client_name = $client->get_name(); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
+
+ // Build form action URL.
+ // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
+ $form_url = \add_query_arg(
+ array(
+ 'action' => 'activitypub_authorize',
+ 'client_id' => $client_id,
+ 'redirect_uri' => $redirect_uri,
+ 'scope' => $scope,
+ 'state' => $state,
+ 'code_challenge' => $code_challenge,
+ 'code_challenge_method' => $code_challenge_method,
+ ),
+ \wp_login_url()
+ );
+
+ // Include the template.
+ include ACTIVITYPUB_PLUGIN_DIR . 'templates/oauth-authorize.php';
+ }
+
+ /**
+ * 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' ) ) {
+ \wp_die(
+ \esc_html__( 'Security check failed. Please try again.', 'activitypub' ),
+ \esc_html__( 'Authorization Error', 'activitypub' ),
+ array( 'response' => 403 )
+ );
+ }
+
+ // 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'] ) ? \esc_url_raw( \wp_unslash( $_POST['redirect_uri'] ) ) : '';
+ $scope = isset( $_POST['scope'] ) ? \sanitize_text_field( \wp_unslash( $_POST['scope'] ) ) : '';
+ $state = isset( $_POST['state'] ) ? \sanitize_text_field( \wp_unslash( $_POST['state'] ) ) : '';
+ $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
+
+ // User denied authorization.
+ if ( ! $approve ) {
+ $error_url = \add_query_arg(
+ array(
+ 'error' => 'access_denied',
+ 'error_description' => \rawurlencode( 'The user denied the authorization request.' ),
+ 'state' => $state,
+ ),
+ $redirect_uri
+ );
+ \wp_safe_redirect( $error_url );
+ exit;
+ }
+
+ // 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 ) ) {
+ $error_url = \add_query_arg(
+ array(
+ 'error' => 'server_error',
+ 'error_description' => \rawurlencode( $code->get_error_message() ),
+ 'state' => $state,
+ ),
+ $redirect_uri
+ );
+ \wp_safe_redirect( $error_url );
+ exit;
+ }
+
+ // Redirect to client with authorization code.
+ $success_url = \add_query_arg(
+ array(
+ 'code' => $code,
+ 'state' => $state,
+ ),
+ $redirect_uri
+ );
+ \wp_redirect( $success_url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Redirecting to external client.
+ exit;
+ }
}
diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php
index 6eb0f33ab1..85b43b6eef 100644
--- a/includes/oauth/class-token.php
+++ b/includes/oauth/class-token.php
@@ -10,43 +10,62 @@
/**
* Token class for managing OAuth 2.0 access and refresh tokens.
*
- * Tokens are stored as Custom Post Types with hashed values for security.
+ * Tokens are stored as user metadata with hashed values for security.
+ * This follows the IndieAuth pattern for efficient token management.
*/
class Token {
/**
- * Post type for OAuth tokens.
+ * User meta key prefix for OAuth tokens.
*/
- const POST_TYPE = 'ap_oauth_token';
+ const META_PREFIX = '_activitypub_oauth_token_';
/**
- * Post status for active tokens.
+ * Option key for tracking users with tokens (for cleanup).
*/
- const STATUS_ACTIVE = 'publish';
+ const USERS_OPTION = 'activitypub_oauth_token_users';
/**
- * Post status for revoked tokens.
+ * Default access token expiration in seconds (1 hour).
*/
- const STATUS_REVOKED = 'draft';
+ const DEFAULT_EXPIRATION = 3600;
/**
- * Default access token expiration in seconds (1 hour).
+ * Refresh token expiration in seconds (30 days).
*/
- const DEFAULT_EXPIRATION = 3600;
+ const REFRESH_EXPIRATION = 2592000;
+
+ /**
+ * The token data array.
+ *
+ * @var array
+ */
+ private $data;
/**
- * The post ID of the token.
+ * The user ID this token belongs to.
*
* @var int
*/
- private $post_id;
+ private $user_id;
+
+ /**
+ * The token key (hash) used for storage.
+ *
+ * @var string
+ */
+ private $token_key;
/**
* Constructor.
*
- * @param int $post_id The post ID of the token.
+ * @param int $user_id The user ID.
+ * @param string $token_key The token key (hash).
+ * @param array $data The token data.
*/
- public function __construct( $post_id ) {
- $this->post_id = $post_id;
+ public function __construct( $user_id, $token_key, $data ) {
+ $this->user_id = $user_id;
+ $this->token_key = $token_key;
+ $this->data = $data;
}
/**
@@ -63,36 +82,37 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D
$access_token = self::generate_token();
$refresh_token = self::generate_token();
- // Calculate expiration.
- $expires_at = time() + $expires;
-
- // Create the token post.
- $post_id = \wp_insert_post(
- array(
- 'post_type' => self::POST_TYPE,
- 'post_status' => self::STATUS_ACTIVE,
- 'post_author' => $user_id,
- 'post_title' => sprintf(
- /* translators: %1$s: client ID, %2$s: user login */
- \__( 'Token for %1$s (%2$s)', 'activitypub' ),
- $client_id,
- \get_userdata( $user_id )->user_login ?? $user_id
- ),
- 'meta_input' => array(
- '_activitypub_access_token_hash' => self::hash_token( $access_token ),
- '_activitypub_refresh_token_hash' => self::hash_token( $refresh_token ),
- '_activitypub_client_id' => $client_id,
- '_activitypub_scopes' => Scope::validate( $scopes ),
- '_activitypub_expires_at' => $expires_at,
- ),
- ),
- true
+ // 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,
);
- if ( \is_wp_error( $post_id ) ) {
- return $post_id;
+ // Store in user meta with access token hash as key.
+ $meta_key = self::META_PREFIX . self::hash_token( $access_token );
+ $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 )
+ );
}
+ // Track user for cleanup.
+ self::track_user( $user_id );
+
return array(
'access_token' => $access_token,
'token_type' => 'Bearer',
@@ -109,40 +129,43 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D
* @return Token|\WP_Error The token object or error.
*/
public static function validate( $token ) {
- $hash = self::hash_token( $token );
-
- // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Token lookup by hash is necessary.
- $posts = \get_posts(
- array(
- 'post_type' => self::POST_TYPE,
- 'post_status' => self::STATUS_ACTIVE,
- 'meta_key' => '_activitypub_access_token_hash',
- 'meta_value' => $hash,
- 'numberposts' => 1,
- )
- );
- // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
-
- if ( empty( $posts ) ) {
- return new \WP_Error(
- 'activitypub_invalid_token',
- \__( 'Invalid or expired access token.', 'activitypub' ),
- array( 'status' => 401 )
- );
- }
-
- $post = $posts[0];
- $expires_at = (int) \get_post_meta( $post->ID, '_activitypub_expires_at', true );
-
- if ( $expires_at < time() ) {
- return new \WP_Error(
- 'activitypub_token_expired',
- \__( 'Access token has expired.', 'activitypub' ),
- array( 'status' => 401 )
- );
+ $token_hash = self::hash_token( $token );
+ $meta_key = self::META_PREFIX . $token_hash;
+
+ // Search for the token across all users with tokens.
+ $users = self::get_tracked_users();
+
+ foreach ( $users as $user_id ) {
+ $token_data = \get_user_meta( $user_id, $meta_key, true );
+
+ if ( ! empty( $token_data ) && is_array( $token_data ) ) {
+ // Verify hash matches.
+ if ( isset( $token_data['access_token_hash'] ) &&
+ hash_equals( $token_data['access_token_hash'], $token_hash ) ) {
+
+ // 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 )
+ );
+ }
+
+ // Update last used timestamp.
+ $token_data['last_used_at'] = time();
+ \update_user_meta( $user_id, $meta_key, $token_data );
+
+ return new self( $user_id, $token_hash, $token_data );
+ }
+ }
}
- return new self( $post->ID );
+ return new \WP_Error(
+ 'activitypub_invalid_token',
+ \__( 'Invalid access token.', 'activitypub' ),
+ array( 'status' => 401 )
+ );
}
/**
@@ -153,95 +176,104 @@ public static function validate( $token ) {
* @return array|\WP_Error New token data or error.
*/
public static function refresh( $refresh_token, $client_id ) {
- $hash = self::hash_token( $refresh_token );
-
- $posts = \get_posts(
- array(
- 'post_type' => self::POST_TYPE,
- 'post_status' => self::STATUS_ACTIVE,
- 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
- 'relation' => 'AND',
- array(
- 'key' => '_activitypub_refresh_token_hash',
- 'value' => $hash,
- ),
- array(
- 'key' => '_activitypub_client_id',
- 'value' => $client_id,
- ),
- ),
- 'numberposts' => 1,
- )
- );
-
- if ( empty( $posts ) ) {
- return new \WP_Error(
- 'activitypub_invalid_refresh_token',
- \__( 'Invalid refresh token.', 'activitypub' ),
- array( 'status' => 401 )
- );
+ $refresh_hash = self::hash_token( $refresh_token );
+ $users = self::get_tracked_users();
+
+ foreach ( $users as $user_id ) {
+ // Get all token meta for this user.
+ $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;
+ }
+
+ // Check if this is our refresh token.
+ if ( isset( $token_data['refresh_token_hash'] ) &&
+ hash_equals( $token_data['refresh_token_hash'], $refresh_hash ) ) {
+
+ // 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.
+ \delete_user_meta( $user_id, $meta_key );
+
+ return new \WP_Error(
+ 'activitypub_refresh_token_expired',
+ \__( 'Refresh token has expired.', 'activitypub' ),
+ array( 'status' => 401 )
+ );
+ }
+
+ // Delete the old token.
+ \delete_user_meta( $user_id, $meta_key );
+
+ // Create a new token.
+ return self::create( $user_id, $client_id, $token_data['scopes'] );
+ }
+ }
}
- $post = $posts[0];
-
- // Get existing data.
- $user_id = $post->post_author;
- $scopes = \get_post_meta( $post->ID, '_activitypub_scopes', true );
-
- // Revoke the old token.
- \wp_update_post(
- array(
- 'ID' => $post->ID,
- 'post_status' => self::STATUS_REVOKED,
- )
+ return new \WP_Error(
+ 'activitypub_invalid_refresh_token',
+ \__( 'Invalid refresh token.', 'activitypub' ),
+ array( 'status' => 401 )
);
-
- // Create a new token.
- return self::create( $user_id, $client_id, $scopes );
}
/**
* Revoke a token.
*
* @param string $token The token to revoke (access or refresh).
- * @return bool True on success.
+ * @return bool True on success (always returns true per RFC 7009).
*/
public static function revoke( $token ) {
- $hash = self::hash_token( $token );
-
- // Check both access and refresh token hashes.
- $posts = \get_posts(
- array(
- 'post_type' => self::POST_TYPE,
- 'post_status' => self::STATUS_ACTIVE,
- 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
- 'relation' => 'OR',
- array(
- 'key' => '_activitypub_access_token_hash',
- 'value' => $hash,
- ),
- array(
- 'key' => '_activitypub_refresh_token_hash',
- 'value' => $hash,
- ),
- ),
- 'numberposts' => 1,
- )
- );
+ $token_hash = self::hash_token( $token );
+ $users = self::get_tracked_users();
- if ( empty( $posts ) ) {
- // Token doesn't exist or already revoked - that's fine per RFC 7009.
- return true;
- }
+ foreach ( $users as $user_id ) {
+ $all_meta = \get_user_meta( $user_id );
- $result = \wp_update_post(
- array(
- 'ID' => $posts[0]->ID,
- 'post_status' => self::STATUS_REVOKED,
- )
- );
+ 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;
+ }
- return ! \is_wp_error( $result );
+ // Check both access and refresh token hashes.
+ if ( ( isset( $token_data['access_token_hash'] ) &&
+ hash_equals( $token_data['access_token_hash'], $token_hash ) ) ||
+ ( isset( $token_data['refresh_token_hash'] ) &&
+ hash_equals( $token_data['refresh_token_hash'], $token_hash ) ) ) {
+
+ \delete_user_meta( $user_id, $meta_key );
+ return true;
+ }
+ }
+ }
+
+ // Token doesn't exist or already revoked - that's fine per RFC 7009.
+ return true;
}
/**
@@ -251,31 +283,95 @@ public static function revoke( $token ) {
* @return int Number of tokens revoked.
*/
public static function revoke_all_for_user( $user_id ) {
- $posts = \get_posts(
- array(
- 'post_type' => self::POST_TYPE,
- 'post_status' => self::STATUS_ACTIVE,
- 'author' => $user_id,
- 'numberposts' => -1,
- )
- );
+ $all_meta = \get_user_meta( $user_id );
+ $count = 0;
- $count = 0;
- foreach ( $posts as $post ) {
- $result = \wp_update_post(
- array(
- 'ID' => $post->ID,
- 'post_status' => self::STATUS_REVOKED,
- )
- );
- if ( ! \is_wp_error( $result ) ) {
+ foreach ( $all_meta as $meta_key => $meta_values ) {
+ if ( 0 === strpos( $meta_key, self::META_PREFIX ) ) {
+ \delete_user_meta( $user_id, $meta_key );
++$count;
}
}
+ // Remove user from tracking if no more tokens.
+ if ( $count > 0 ) {
+ self::untrack_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 ) {
+ $users = self::get_tracked_users();
+ $count = 0;
+
+ foreach ( $users as $user_id ) {
+ $all_meta = \get_user_meta( $user_id );
+ $user_tokens = 0;
+
+ 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;
+ }
+
+ // Check if this token belongs to the client.
+ if ( isset( $token_data['client_id'] ) && $token_data['client_id'] === $client_id ) {
+ \delete_user_meta( $user_id, $meta_key );
+ ++$count;
+ } else {
+ ++$user_tokens;
+ }
+ }
+
+ // Untrack user if no more tokens.
+ if ( 0 === $user_tokens ) {
+ self::untrack_user( $user_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;
+ $tokens[] = $token_data;
+ }
+ }
+
+ return $tokens;
+ }
+
/**
* Check if token has a specific scope.
*
@@ -293,8 +389,7 @@ public function has_scope( $scope ) {
* @return int The WordPress user ID.
*/
public function get_user_id() {
- $post = \get_post( $this->post_id );
- return $post ? (int) $post->post_author : 0;
+ return $this->user_id;
}
/**
@@ -303,7 +398,7 @@ public function get_user_id() {
* @return string The OAuth client ID.
*/
public function get_client_id() {
- return \get_post_meta( $this->post_id, '_activitypub_client_id', true );
+ return $this->data['client_id'] ?? '';
}
/**
@@ -312,8 +407,7 @@ public function get_client_id() {
* @return array The granted scopes.
*/
public function get_scopes() {
- $scopes = \get_post_meta( $this->post_id, '_activitypub_scopes', true );
- return is_array( $scopes ) ? $scopes : array();
+ return $this->data['scopes'] ?? array();
}
/**
@@ -322,7 +416,7 @@ public function get_scopes() {
* @return int Unix timestamp.
*/
public function get_expires_at() {
- return (int) \get_post_meta( $this->post_id, '_activitypub_expires_at', true );
+ return $this->data['expires_at'] ?? 0;
}
/**
@@ -334,6 +428,24 @@ 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.
*
@@ -354,6 +466,43 @@ public static function hash_token( $token ) {
return hash( 'sha256', $token );
}
+ /**
+ * Track a user as having tokens.
+ *
+ * @param int $user_id The user ID.
+ */
+ private static function track_user( $user_id ) {
+ $users = self::get_tracked_users();
+ if ( ! in_array( $user_id, $users, true ) ) {
+ $users[] = $user_id;
+ \update_option( self::USERS_OPTION, $users, false );
+ }
+ }
+
+ /**
+ * Untrack a user (when they have no more tokens).
+ *
+ * @param int $user_id The user ID.
+ */
+ private static function untrack_user( $user_id ) {
+ $users = self::get_tracked_users();
+ $key = array_search( $user_id, $users, true );
+ if ( false !== $key ) {
+ unset( $users[ $key ] );
+ \update_option( self::USERS_OPTION, array_values( $users ), false );
+ }
+ }
+
+ /**
+ * Get all tracked users with tokens.
+ *
+ * @return array User IDs.
+ */
+ private static function get_tracked_users() {
+ $users = \get_option( self::USERS_OPTION, array() );
+ return is_array( $users ) ? $users : array();
+ }
+
/**
* Clean up expired tokens.
*
@@ -362,27 +511,74 @@ public static function hash_token( $token ) {
* @return int Number of tokens deleted.
*/
public static function cleanup_expired() {
- global $wpdb;
-
- $expired_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
- $wpdb->prepare(
- "SELECT p.ID FROM {$wpdb->posts} p
- INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
- WHERE p.post_type = %s
- AND pm.meta_key = '_activitypub_expires_at'
- AND pm.meta_value < %d",
- self::POST_TYPE,
- time() - DAY_IN_SECONDS // Grace period of 1 day.
- )
- );
-
+ $users = self::get_tracked_users();
$count = 0;
- foreach ( $expired_ids as $post_id ) {
- if ( \wp_delete_post( $post_id, true ) ) {
- ++$count;
+
+ foreach ( $users as $user_id ) {
+ $all_meta = \get_user_meta( $user_id );
+ $user_tokens = 0;
+
+ 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 );
+ ++$count;
+ } else {
+ ++$user_tokens;
+ }
+ }
+
+ // Untrack user if no more tokens.
+ if ( 0 === $user_tokens ) {
+ self::untrack_user( $user_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 = \get_userdata( $validated->get_user_id() );
+
+ 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) $validated->get_user_id(),
+ );
+ }
}
diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php
index f45de9751d..c52b3d8f3b 100644
--- a/includes/rest/class-oauth-controller.php
+++ b/includes/rest/class-oauth-controller.php
@@ -114,6 +114,31 @@ public function register_routes() {
)
);
+ // Token introspection endpoint (RFC 7662).
+ \register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base . '/introspect',
+ array(
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'introspect' ),
+ 'permission_callback' => '__return_true',
+ 'args' => array(
+ 'token' => array(
+ 'description' => 'The token to introspect.',
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'token_type_hint' => array(
+ 'description' => 'Hint about the token type.',
+ 'type' => 'string',
+ 'enum' => array( 'access_token', 'refresh_token' ),
+ ),
+ ),
+ ),
+ )
+ );
+
// Dynamic client registration (RFC 7591).
\register_rest_route(
$this->namespace,
@@ -169,7 +194,7 @@ public function register_routes() {
/**
* Handle authorization request (GET /oauth/authorize).
*
- * Displays authorization page or redirects to WP login.
+ * Validates request parameters and redirects to wp-admin consent page.
*
* @param \WP_REST_Request $request The request object.
* @return \WP_REST_Response|\WP_Error
@@ -226,34 +251,27 @@ public function authorize( \WP_REST_Request $request ) {
);
}
- // If user is not logged in, redirect to login page.
- if ( ! \is_user_logged_in() ) {
- $login_url = \wp_login_url( $request->get_uri() );
- return new \WP_REST_Response(
- null,
- 302,
- array( 'Location' => $login_url )
- );
- }
-
- // User is logged in - display consent page.
- $scopes = Scope::validate( Scope::parse( $scope ) );
- $user = \wp_get_current_user();
- $nonce = \wp_create_nonce( 'activitypub_oauth_authorize' );
-
- // Build consent page HTML.
- $html = $this->render_consent_page(
- $client,
- $scopes,
- $user,
- $request->get_params(),
- $nonce
+ // Redirect to wp-login.php with action=activitypub_authorize.
+ // This uses WordPress's login_form_{action} hook for proper cookie auth.
+ $login_url = \wp_login_url();
+ $login_url = \add_query_arg(
+ array(
+ 'action' => 'activitypub_authorize',
+ 'client_id' => $client_id,
+ 'redirect_uri' => $redirect_uri,
+ 'response_type' => $response_type,
+ 'scope' => $scope,
+ 'state' => $state,
+ 'code_challenge' => $code_challenge,
+ 'code_challenge_method' => $request->get_param( 'code_challenge_method' ) ?: 'S256',
+ ),
+ $login_url
);
return new \WP_REST_Response(
- $html,
- 200,
- array( 'Content-Type' => 'text/html; charset=' . \get_option( 'blog_charset' ) )
+ null,
+ 302,
+ array( 'Location' => $login_url )
);
}
@@ -467,6 +485,23 @@ public function revoke( \WP_REST_Request $request ) {
return new \WP_REST_Response( null, 200 );
}
+ /**
+ * Handle token introspection (POST /oauth/introspect).
+ *
+ * Implements RFC 7662 Token Introspection.
+ *
+ * @param \WP_REST_Request $request The request object.
+ * @return \WP_REST_Response
+ */
+ public function introspect( \WP_REST_Request $request ) {
+ $token = $request->get_param( 'token' );
+
+ // Introspect the token.
+ $response = Token::introspect( $token );
+
+ return new \WP_REST_Response( $response, 200 );
+ }
+
/**
* Handle dynamic client registration (POST /oauth/clients).
*
diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php
index cf84aa98f0..d41e48d98e 100644
--- a/includes/rest/class-outbox-controller.php
+++ b/includes/rest/class-outbox-controller.php
@@ -403,6 +403,9 @@ public function create_item( $request ) {
$data = $this->wrap_in_create( $data, $user );
}
+ // Ensure the object has an ID (required for outbox storage).
+ $data = $this->ensure_object_id( $data, $user );
+
$activity_type = camel_to_snake_case( $data['type'] ?? '' );
// Determine visibility from addressing.
@@ -578,4 +581,41 @@ private function determine_visibility( $activity ) {
// Private (no public addressing).
return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE;
}
+
+ /**
+ * Ensure the activity object has an ID.
+ *
+ * For C2S activities, clients may not provide object IDs.
+ * The server must generate them.
+ *
+ * @param array $data The activity data.
+ * @param \Activitypub\Model\User|null $user The authenticated user.
+ * @return array The activity data with object ID ensured.
+ */
+ private function ensure_object_id( $data, $user ) {
+ // Check if there's an embedded object that needs an ID.
+ if ( ! isset( $data['object'] ) || ! is_array( $data['object'] ) ) {
+ return $data;
+ }
+
+ $object = &$data['object'];
+
+ // Generate ID if missing.
+ if ( empty( $object['id'] ) ) {
+ $uuid = \wp_generate_uuid4();
+ $object['id'] = get_rest_url_by_path( 'objects/' . $uuid );
+ }
+
+ // Set attributedTo if missing.
+ if ( empty( $object['attributedTo'] ) && $user ) {
+ $object['attributedTo'] = $user->get_id();
+ }
+
+ // Set published if missing.
+ if ( empty( $object['published'] ) ) {
+ $object['published'] = \gmdate( 'Y-m-d\TH:i:s\Z' );
+ }
+
+ return $data;
+ }
}
diff --git a/templates/oauth-authorize.php b/templates/oauth-authorize.php
new file mode 100644
index 0000000000..145fee0251
--- /dev/null
+++ b/templates/oauth-authorize.php
@@ -0,0 +1,112 @@
+
+
+
+
+assertInstanceOf( \WP_Error::class, $result );
- $this->assertEquals( 'activitypub_invalid_code', $result->get_error_code() );
+ $this->assertEquals( 'activitypub_client_mismatch', $result->get_error_code() );
}
/**
diff --git a/tests/phpunit/tests/includes/oauth/class-test-token.php b/tests/phpunit/tests/includes/oauth/class-test-token.php
index 7428872d4d..1e08b3ef50 100644
--- a/tests/phpunit/tests/includes/oauth/class-test-token.php
+++ b/tests/phpunit/tests/includes/oauth/class-test-token.php
@@ -270,7 +270,7 @@ public function test_refresh_wrong_client() {
$result = Token::refresh( $original['refresh_token'], 'wrong_client_id' );
$this->assertInstanceOf( \WP_Error::class, $result );
- $this->assertEquals( 'activitypub_invalid_refresh_token', $result->get_error_code() );
+ $this->assertEquals( 'activitypub_client_mismatch', $result->get_error_code() );
}
/**
From e20d0770c45980f56bbba77a586399040e2f4aae Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 1 Feb 2026 17:51:45 +0100
Subject: [PATCH 011/105] Refactor handlers to use incoming/outgoing naming
convention
- Rename handler methods to `incoming()` for inbox and `outgoing()` for outbox
- Add deprecated proxy functions for backward compatibility (handle_*)
- Update Create handler to support outbox POST with WordPress post creation
- Add Dispatcher hook to fire outbox handlers after add_to_outbox()
- Skip scheduler for already-federated posts to prevent duplicates
- Remove C2S terminology from comments, use incoming/outgoing instead
Handlers updated: Create, Update, Announce, Like, Undo, Follow, Delete
---
includes/class-dispatcher.php | 34 ++++
includes/handler/class-announce.php | 45 ++++-
includes/handler/class-create.php | 223 ++++++++++++++--------
includes/handler/class-delete.php | 44 ++++-
includes/handler/class-follow.php | 52 +++--
includes/handler/class-like.php | 44 ++++-
includes/handler/class-undo.php | 44 ++++-
includes/handler/class-update.php | 47 ++++-
includes/rest/class-outbox-controller.php | 89 ++++-----
includes/scheduler/class-post.php | 6 +
10 files changed, 446 insertions(+), 182 deletions(-)
diff --git a/includes/class-dispatcher.php b/includes/class-dispatcher.php
index c2f9793244..c89f8f1699 100644
--- a/includes/class-dispatcher.php
+++ b/includes/class-dispatcher.php
@@ -34,6 +34,7 @@ class Dispatcher {
public static function init() {
\add_action( 'activitypub_process_outbox', array( self::class, 'process_outbox' ) );
+ \add_action( 'post_activitypub_add_to_outbox', array( self::class, 'fire_outbox_handlers' ), 5, 2 );
\add_action( 'post_activitypub_add_to_outbox', array( self::class, 'send_immediate_accept' ), 10, 2 );
// Default filters to add Inboxes to sent to.
@@ -511,6 +512,39 @@ public static function add_inboxes_of_relays( $inboxes, $actor_id, $activity ) {
return array_merge( $inboxes, $relays );
}
+ /**
+ * Fire outbox handlers for activities.
+ *
+ * Triggers activity type-specific handlers to process outbox activities,
+ * allowing handlers to create WordPress posts or perform other side effects.
+ *
+ * @param int $outbox_id The Outbox item ID.
+ * @param Activity $activity The Activity that was just added to the Outbox.
+ */
+ public static function fire_outbox_handlers( $outbox_id, $activity ) {
+ $outbox_item = \get_post( $outbox_id );
+
+ if ( ! $outbox_item ) {
+ return;
+ }
+
+ $type = $activity->get_type();
+ $user_id = $outbox_item->post_author;
+ $data = $activity->to_array( false );
+
+ /**
+ * Fires when an activity has been added to the outbox.
+ *
+ * Handlers can implement side effects like creating WordPress posts.
+ *
+ * @param array $data The activity data array.
+ * @param int $user_id The user ID.
+ * @param Activity $activity The Activity object.
+ * @param int $outbox_id The outbox post ID.
+ */
+ \do_action( 'activitypub_handled_outbox_' . \strtolower( $type ), $data, $user_id, $activity, $outbox_id );
+ }
+
/**
* Send an immediate Accept activity for the given Outbox item.
*
diff --git a/includes/handler/class-announce.php b/includes/handler/class-announce.php
index 91d0c7f022..bcfdda39d0 100644
--- a/includes/handler/class-announce.php
+++ b/includes/handler/class-announce.php
@@ -24,18 +24,18 @@ class Announce {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
- \add_action( 'activitypub_inbox_announce', array( self::class, 'handle_announce' ), 10, 3 );
- \add_action( 'activitypub_handled_outbox_announce', array( self::class, 'handle_outbox_announce' ), 10, 4 );
+ \add_action( 'activitypub_inbox_announce', array( self::class, 'incoming' ), 10, 3 );
+ \add_action( 'activitypub_handled_outbox_announce', array( self::class, 'outgoing' ), 10, 4 );
}
/**
- * Handles "Announce" requests.
+ * Handle incoming "Announce" requests from remote actors.
*
* @param array $announcement The activity-object.
* @param int|int[] $user_ids The id(s) of the local blog-user(s).
* @param \Activitypub\Activity\Activity $activity The activity object.
*/
- public static function handle_announce( $announcement, $user_ids, $activity = null ) {
+ public static function incoming( $announcement, $user_ids, $activity = null ) {
// Check if Activity is public or not.
if ( ! is_activity_public( $announcement ) ) {
// @todo maybe send email
@@ -133,7 +133,7 @@ public static function maybe_save_announce( $activity, $user_ids ) {
}
/**
- * Handle outbox "Announce" activities (C2S).
+ * Handle outgoing "Announce" activities from local actors.
*
* Records an announce/boost from the local user on remote content.
*
@@ -142,7 +142,7 @@ public static function maybe_save_announce( $activity, $user_ids ) {
* @param \Activitypub\Activity\Activity $activity The Activity object.
* @param int $outbox_id The outbox post ID.
*/
- public static function handle_outbox_announce( $data, $user_id, $activity, $outbox_id ) {
+ public static function outgoing( $data, $user_id, $activity, $outbox_id ) {
$object_url = object_to_uri( $data['object'] ?? '' );
if ( empty( $object_url ) ) {
@@ -150,7 +150,7 @@ public static function handle_outbox_announce( $data, $user_id, $activity, $outb
}
/**
- * Fires after an Announce activity has been sent via C2S.
+ * Fires after an outgoing Announce activity has been processed.
*
* @param string $object_url The URL of the announced object.
* @param array $data The activity data.
@@ -159,4 +159,35 @@ public static function handle_outbox_announce( $data, $user_id, $activity, $outb
*/
\do_action( 'activitypub_outbox_announce_sent', $object_url, $data, $user_id, $outbox_id );
}
+
+ /**
+ * Handle "Announce" requests.
+ *
+ * @deprecated unreleased Use Announce::incoming() instead.
+ *
+ * @param array $announcement The activity-object.
+ * @param int|int[] $user_ids The id(s) of the local blog-user(s).
+ * @param \Activitypub\Activity\Activity $activity The activity object.
+ */
+ public static function handle_announce( $announcement, $user_ids, $activity = null ) {
+ \_deprecated_function( __METHOD__, 'unreleased', 'Announce::incoming()' );
+
+ return self::incoming( $announcement, $user_ids, $activity );
+ }
+
+ /**
+ * Handle outbox "Announce" activities.
+ *
+ * @deprecated unreleased Use Announce::outgoing() instead.
+ *
+ * @param array $data The activity data array.
+ * @param int $user_id The user ID.
+ * @param \Activitypub\Activity\Activity $activity The Activity object.
+ * @param int $outbox_id The outbox post ID.
+ */
+ public static function handle_outbox_announce( $data, $user_id, $activity, $outbox_id ) {
+ \_deprecated_function( __METHOD__, 'unreleased', 'Announce::outgoing()' );
+
+ return self::outgoing( $data, $user_id, $activity, $outbox_id );
+ }
}
diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php
index 33b34af56d..228efb4c08 100644
--- a/includes/handler/class-create.php
+++ b/includes/handler/class-create.php
@@ -11,7 +11,9 @@
use Activitypub\Collection\Posts;
use Activitypub\Tombstone;
+use function Activitypub\add_to_outbox;
use function Activitypub\get_activity_visibility;
+use function Activitypub\get_content_visibility;
use function Activitypub\is_activity_reply;
use function Activitypub\is_quote_activity;
use function Activitypub\is_self_ping;
@@ -25,127 +27,109 @@ class Create {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
- \add_action( 'activitypub_handled_inbox_create', array( self::class, 'handle_create' ), 10, 3 );
- \add_action( 'activitypub_handled_outbox_create', array( self::class, 'handle_outbox_create' ), 10, 4 );
+ // Incoming activities (from remote actors via inbox).
+ \add_action( 'activitypub_handled_inbox_create', array( self::class, 'incoming' ), 10, 3 );
+
+ // Outgoing activities (from local actors via outbox).
+ \add_filter( 'activitypub_outbox_create', array( self::class, 'outgoing' ), 10, 3 );
+
\add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 );
\add_action( 'post_activitypub_add_to_outbox', array( self::class, 'maybe_unbury' ), 10, 2 );
}
/**
- * Handles "Create" requests.
+ * Handle incoming "Create" activities from remote actors.
+ *
+ * @param array $activity The activity data.
+ * @param int[] $user_ids The local user IDs targeted.
+ * @param mixed $activity_object The activity object (unused, required by hook signature).
*
- * @param array $activity The activity-object.
- * @param int|int[] $user_ids The id(s) of the local blog-user(s).
- * @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null.
+ * @return \WP_Post|\WP_Comment|\WP_Error|false The created content or error.
*/
- public static function handle_create( $activity, $user_ids, $activity_object = null ) {
+ public static function incoming( $activity, $user_ids = null, $activity_object = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
// Check for private and/or direct messages.
if ( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === get_activity_visibility( $activity ) ) {
- $result = false;
- } elseif ( is_activity_reply( $activity ) || is_quote_activity( $activity ) ) { // Check for replies and quotes.
- $result = self::create_interaction( $activity, $user_ids, $activity_object );
- } else { // Handle non-interaction objects.
- $result = self::create_post( $activity, $user_ids, $activity_object );
+ return false;
+ }
+
+ // Route to appropriate handler based on content type.
+ if ( is_activity_reply( $activity ) || is_quote_activity( $activity ) ) {
+ $result = self::incoming_interaction( $activity, $user_ids );
+ } else {
+ $result = self::incoming_post( $activity, $user_ids );
}
if ( false === $result ) {
- return;
+ return $result;
}
$success = ! \is_wp_error( $result );
/**
- * Fires after an ActivityPub Create activity has been handled.
+ * Fires after an incoming ActivityPub Create activity has been handled.
*
* @param array $activity The ActivityPub activity data.
* @param int[] $user_ids The local user IDs.
* @param bool $success True on success, false otherwise.
- * @param \WP_Comment|\WP_Post|\WP_Error $result The WP_Comment object of the created comment, or null if creation failed.
+ * @param \WP_Comment|\WP_Post|\WP_Error $result The created content or error.
*/
\do_action( 'activitypub_handled_create', $activity, (array) $user_ids, $success, $result );
+
+ return $result;
}
/**
- * Handle outbox "Create" activities (C2S).
+ * Handle outgoing "Create" activities from local actors.
*
- * Creates a WordPress post from the ActivityPub object.
+ * Creates WordPress content and adds to outbox for federation.
*
- * @param array $data The activity data array.
- * @param int $user_id The user ID.
- * @param \Activitypub\Activity\Activity $activity The Activity object.
- * @param int $outbox_id The outbox post ID.
+ * @param array $activity The activity data.
+ * @param int $user_id The local user ID.
+ * @param string|null $visibility Content visibility.
+ *
+ * @return int|\WP_Error|null The outbox ID on success, WP_Error on failure, null if not handled.
*/
- public static function handle_outbox_create( $data, $user_id, $activity, $outbox_id ) {
- $object = $data['object'] ?? array();
-
- if ( ! is_array( $object ) ) {
- return;
+ public static function outgoing( $activity, $user_id = null, $visibility = null ) {
+ // Check for private and/or direct messages.
+ if ( ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === get_activity_visibility( $activity ) ) {
+ return false;
}
- $type = $object['type'] ?? '';
+ $object = $activity['object'] ?? array();
- // Only handle Note and Article types.
- if ( ! in_array( $type, array( 'Note', 'Article' ), true ) ) {
- return;
+ if ( ! \is_array( $object ) ) {
+ return new \WP_Error( 'invalid_object', 'Invalid object in activity.' );
}
- $content = $object['content'] ?? '';
- $name = $object['name'] ?? '';
- $summary = $object['summary'] ?? '';
+ $object_type = $object['type'] ?? '';
- // Use name as title for Articles, or generate from content for Notes.
- $title = $name;
- if ( empty( $title ) && ! empty( $content ) ) {
- $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' );
+ // Only handle Note and Article types for now.
+ if ( ! \in_array( $object_type, array( 'Note', 'Article' ), true ) ) {
+ return null;
}
- // Determine visibility.
- $visibility = \get_post_meta( $outbox_id, 'activitypub_content_visibility', true );
-
- $post_data = array(
- 'post_author' => $user_id > 0 ? $user_id : 0,
- 'post_title' => $title,
- 'post_content' => $content,
- 'post_excerpt' => $summary,
- 'post_status' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ? 'private' : 'publish',
- 'post_type' => 'post',
- 'meta_input' => array(
- 'activitypub_content_visibility' => $visibility,
- ),
- );
-
- $post_id = \wp_insert_post( $post_data, true );
-
- if ( \is_wp_error( $post_id ) ) {
- return;
+ // TODO: Handle replies/interactions differently.
+ if ( is_activity_reply( $activity ) || is_quote_activity( $activity ) ) {
+ return null;
}
- /**
- * Fires after a post has been created from a C2S Create activity.
- *
- * @param int $post_id The created post ID.
- * @param array $data The activity data.
- * @param int $user_id The user ID.
- * @param int $outbox_id The outbox post ID.
- */
- \do_action( 'activitypub_outbox_created_post', $post_id, $data, $user_id, $outbox_id );
+ return self::outgoing_post( $activity, $user_id, $visibility );
}
/**
- * Handle interactions like replies.
+ * Handle incoming interaction (reply/quote) from remote actor.
*
- * @param array $activity The activity-object.
- * @param int[] $user_ids The ids of the local blog-users.
- * @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null.
+ * @param array $activity The activity data.
+ * @param int[] $user_ids The local user IDs targeted.
*
- * @return \WP_Comment|\WP_Error|false The created comment, WP_Error on failure, false if already exists or not processed.
+ * @return \WP_Comment|\WP_Error|false Comment, WP_Error, or false.
*/
- public static function create_interaction( $activity, $user_ids, $activity_object = null ) {
+ private static function incoming_interaction( $activity, $user_ids ) {
$existing_comment = object_id_to_comment( $activity['object']['id'] );
// If comment exists, call update action.
if ( $existing_comment ) {
- Update::handle_update( $activity, (array) $user_ids, $activity_object );
+ Update::incoming( $activity, (array) $user_ids, null );
return false;
}
@@ -164,15 +148,14 @@ public static function create_interaction( $activity, $user_ids, $activity_objec
}
/**
- * Handle non-interaction posts like posts.
+ * Handle incoming post from remote actor.
*
- * @param array $activity The activity-object.
- * @param int[] $user_ids The ids of the local blog-users.
- * @param \Activitypub\Activity\Activity $activity_object Optional. The activity object. Default null.
+ * @param array $activity The activity data.
+ * @param int[] $user_ids The local user IDs targeted.
*
- * @return \WP_Post|\WP_Error|false The post on success, WP_Error on failure, false if already exists.
+ * @return \WP_Post|\WP_Error|false Post, WP_Error, or false.
*/
- public static function create_post( $activity, $user_ids, $activity_object = null ) {
+ private static function incoming_post( $activity, $user_ids ) {
if ( ! \get_option( 'activitypub_create_posts', false ) ) {
return false;
}
@@ -181,7 +164,7 @@ public static function create_post( $activity, $user_ids, $activity_object = nul
// If post exists, call update action.
if ( $existing_post instanceof \WP_Post ) {
- Update::handle_update( $activity, (array) $user_ids, $activity_object );
+ Update::incoming( $activity, (array) $user_ids, null );
return false;
}
@@ -189,6 +172,71 @@ public static function create_post( $activity, $user_ids, $activity_object = nul
return Posts::add( $activity, $user_ids );
}
+ /**
+ * Handle outgoing post from local actor.
+ *
+ * Creates a WordPress post and adds to outbox for federation.
+ *
+ * @param array $activity The activity data.
+ * @param int $user_id The local user ID.
+ * @param string|null $visibility Content visibility.
+ *
+ * @return int|\WP_Error The outbox ID on success, WP_Error on failure.
+ */
+ private static function outgoing_post( $activity, $user_id, $visibility ) {
+ $object = $activity['object'] ?? array();
+
+ $content = $object['content'] ?? '';
+ $name = $object['name'] ?? '';
+ $summary = $object['summary'] ?? '';
+
+ // Use name as title for Articles, or generate from content for Notes.
+ $title = $name;
+ if ( empty( $title ) && ! empty( $content ) ) {
+ $title = \wp_trim_words( \wp_strip_all_tags( $content ), 10, '...' );
+ }
+
+ // Determine visibility if not provided.
+ if ( null === $visibility ) {
+ $visibility = get_content_visibility( $activity );
+ }
+
+ $post_data = array(
+ 'post_author' => $user_id > 0 ? $user_id : 0,
+ 'post_title' => $title,
+ 'post_content' => $content,
+ 'post_excerpt' => $summary,
+ 'post_status' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === $visibility ? 'private' : 'publish',
+ 'post_type' => 'post',
+ 'meta_input' => array(
+ 'activitypub_content_visibility' => $visibility,
+ // Mark the post as federated to prevent the scheduler from also adding it to outbox.
+ 'activitypub_status' => ACTIVITYPUB_OBJECT_STATE_FEDERATED,
+ ),
+ );
+
+ $post_id = \wp_insert_post( $post_data, true );
+
+ if ( \is_wp_error( $post_id ) ) {
+ return $post_id;
+ }
+
+ $post = \get_post( $post_id );
+
+ /**
+ * Fires after a post has been created from an outgoing Create activity.
+ *
+ * @param int $post_id The created post ID.
+ * @param array $activity The activity data.
+ * @param int $user_id The user ID.
+ * @param string $visibility The content visibility.
+ */
+ \do_action( 'activitypub_outbox_created_post', $post_id, $activity, $user_id, $visibility );
+
+ // Add to outbox and return the outbox ID.
+ return add_to_outbox( $post, 'Create', $user_id, $visibility );
+ }
+
/**
* Validate the object.
*
@@ -242,4 +290,21 @@ public static function maybe_unbury( $outbox_id, $activity ) {
Tombstone::remove( $object->get_url() );
}
}
+
+ /**
+ * Handle "Create" requests.
+ *
+ * @deprecated unreleased Use Create::incoming() instead.
+ *
+ * @param array $activity The activity data.
+ * @param int[] $user_ids The local user IDs targeted.
+ * @param mixed $activity_object The activity object.
+ *
+ * @return \WP_Post|\WP_Comment|\WP_Error|false The created content or error.
+ */
+ public static function handle_create( $activity, $user_ids = null, $activity_object = null ) {
+ \_deprecated_function( __METHOD__, 'unreleased', 'Create::incoming()' );
+
+ return self::incoming( $activity, $user_ids, $activity_object );
+ }
}
diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php
index 8e4f6f0495..6081181e4c 100644
--- a/includes/handler/class-delete.php
+++ b/includes/handler/class-delete.php
@@ -22,8 +22,8 @@ class Delete {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
- \add_action( 'activitypub_inbox_delete', array( self::class, 'handle_delete' ), 10, 2 );
- \add_action( 'activitypub_handled_outbox_delete', array( self::class, 'handle_outbox_delete' ), 10, 4 );
+ \add_action( 'activitypub_inbox_delete', array( self::class, 'incoming' ), 10, 2 );
+ \add_action( 'activitypub_handled_outbox_delete', array( self::class, 'outgoing' ), 10, 4 );
\add_filter( 'activitypub_skip_inbox_storage', array( self::class, 'skip_inbox_storage' ), 10, 2 );
\add_filter( 'activitypub_defer_signature_verification', array( self::class, 'defer_signature_verification' ), 10, 2 );
\add_action( 'activitypub_delete_remote_actor_interactions', array( self::class, 'delete_interactions' ) );
@@ -34,12 +34,12 @@ public static function init() {
}
/**
- * Handles "Delete" requests.
+ * Handle incoming "Delete" requests from remote actors.
*
* @param array $activity The delete activity.
* @param int|int[] $user_ids The local user ID(s).
*/
- public static function handle_delete( $activity, $user_ids ) {
+ public static function incoming( $activity, $user_ids ) {
$object_type = $activity['object']['type'] ?? '';
switch ( $object_type ) {
@@ -356,7 +356,7 @@ public static function maybe_bury( $outbox_id, $activity ) {
}
/**
- * Handle outbox "Delete" activities (C2S).
+ * Handle outgoing "Delete" activities from local actors.
*
* Deletes a WordPress post.
*
@@ -365,7 +365,7 @@ public static function maybe_bury( $outbox_id, $activity ) {
* @param \Activitypub\Activity\Activity $activity The Activity object.
* @param int $outbox_id The outbox post ID.
*/
- public static function handle_outbox_delete( $data, $user_id, $activity, $outbox_id ) {
+ public static function outgoing( $data, $user_id, $activity, $outbox_id ) {
$object = $data['object'] ?? '';
// Get the object ID (can be a string URL or an object with an id).
@@ -395,7 +395,7 @@ public static function handle_outbox_delete( $data, $user_id, $activity, $outbox
}
/**
- * Fires after a post has been deleted from a C2S Delete activity.
+ * Fires after a post has been deleted from an outgoing Delete activity.
*
* @param int $post_id The deleted post ID.
* @param array $data The activity data.
@@ -404,4 +404,34 @@ public static function handle_outbox_delete( $data, $user_id, $activity, $outbox
*/
\do_action( 'activitypub_outbox_deleted_post', $post->ID, $data, $user_id, $outbox_id );
}
+
+ /**
+ * Handle "Delete" requests.
+ *
+ * @deprecated unreleased Use Delete::incoming() instead.
+ *
+ * @param array $activity The delete activity.
+ * @param int|int[] $user_ids The local user ID(s).
+ */
+ public static function handle_delete( $activity, $user_ids ) {
+ \_deprecated_function( __METHOD__, 'unreleased', 'Delete::incoming()' );
+
+ return self::incoming( $activity, $user_ids );
+ }
+
+ /**
+ * Handle outbox "Delete" activities.
+ *
+ * @deprecated unreleased Use Delete::outgoing() instead.
+ *
+ * @param array $data The activity data array.
+ * @param int $user_id The user ID.
+ * @param \Activitypub\Activity\Activity $activity The Activity object.
+ * @param int $outbox_id The outbox post ID.
+ */
+ public static function handle_outbox_delete( $data, $user_id, $activity, $outbox_id ) {
+ \_deprecated_function( __METHOD__, 'unreleased', 'Delete::outgoing()' );
+
+ return self::outgoing( $data, $user_id, $activity, $outbox_id );
+ }
}
diff --git a/includes/handler/class-follow.php b/includes/handler/class-follow.php
index db3e39b636..47faac2e4e 100644
--- a/includes/handler/class-follow.php
+++ b/includes/handler/class-follow.php
@@ -23,18 +23,18 @@ class Follow {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
- \add_action( 'activitypub_inbox_follow', array( self::class, 'handle_follow' ), 10, 2 );
+ \add_action( 'activitypub_inbox_follow', array( self::class, 'incoming' ), 10, 2 );
\add_action( 'activitypub_handled_follow', array( self::class, 'queue_accept' ), 10, 4 );
- \add_action( 'activitypub_handled_outbox_follow', array( self::class, 'handle_outbox_follow' ), 10, 4 );
+ \add_action( 'activitypub_handled_outbox_follow', array( self::class, 'outgoing' ), 10, 4 );
}
/**
- * Handle "Follow" requests.
+ * Handle incoming "Follow" requests from remote actors.
*
* @param array $activity The activity object.
* @param int|int[] $user_ids The user ID(s).
*/
- public static function handle_follow( $activity, $user_ids ) {
+ public static function incoming( $activity, $user_ids ) {
// Extract the user ID (follow requests are always for a single user).
$user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids;
@@ -152,7 +152,7 @@ public static function queue_reject( $activity, $user_id ) {
}
/**
- * Handle outbox "Follow" activities (C2S).
+ * Handle outgoing "Follow" activities from local actors.
*
* Adds the target actor to the user's following list (pending until accepted).
*
@@ -161,7 +161,7 @@ public static function queue_reject( $activity, $user_id ) {
* @param \Activitypub\Activity\Activity $activity The Activity object.
* @param int $outbox_id The outbox post ID.
*/
- public static function handle_outbox_follow( $data, $user_id, $activity, $outbox_id ) {
+ public static function outgoing( $data, $user_id, $activity, $outbox_id ) {
$object = $data['object'] ?? '';
// The object should be the actor URL to follow.
@@ -189,13 +189,43 @@ public static function handle_outbox_follow( $data, $user_id, $activity, $outbox
\add_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, (string) $user_id );
/**
- * Fires after a Follow activity has been sent via C2S.
+ * Fires after an outgoing Follow activity has been processed.
*
- * @param int $remote_actor_id The remote actor post ID.
- * @param array $data The activity data.
- * @param int $user_id The user ID.
- * @param int $outbox_id The outbox post ID.
+ * @param int $remote_actor_id The remote actor post ID.
+ * @param array $data The activity data.
+ * @param int $user_id The user ID.
+ * @param int $outbox_id The outbox post ID.
*/
\do_action( 'activitypub_outbox_follow_sent', $remote_actor->ID, $data, $user_id, $outbox_id );
}
+
+ /**
+ * Handle "Follow" requests.
+ *
+ * @deprecated unreleased Use Follow::incoming() instead.
+ *
+ * @param array $activity The activity object.
+ * @param int|int[] $user_ids The user ID(s).
+ */
+ public static function handle_follow( $activity, $user_ids ) {
+ \_deprecated_function( __METHOD__, 'unreleased', 'Follow::incoming()' );
+
+ return self::incoming( $activity, $user_ids );
+ }
+
+ /**
+ * Handle outbox "Follow" activities.
+ *
+ * @deprecated unreleased Use Follow::outgoing() instead.
+ *
+ * @param array $data The activity data array.
+ * @param int $user_id The user ID.
+ * @param \Activitypub\Activity\Activity $activity The Activity object.
+ * @param int $outbox_id The outbox post ID.
+ */
+ public static function handle_outbox_follow( $data, $user_id, $activity, $outbox_id ) {
+ \_deprecated_function( __METHOD__, 'unreleased', 'Follow::outgoing()' );
+
+ return self::outgoing( $data, $user_id, $activity, $outbox_id );
+ }
}
diff --git a/includes/handler/class-like.php b/includes/handler/class-like.php
index fe77e36b94..837ff5de57 100644
--- a/includes/handler/class-like.php
+++ b/includes/handler/class-like.php
@@ -20,18 +20,18 @@ class Like {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
- \add_action( 'activitypub_inbox_like', array( self::class, 'handle_like' ), 10, 2 );
- \add_action( 'activitypub_handled_outbox_like', array( self::class, 'handle_outbox_like' ), 10, 4 );
+ \add_action( 'activitypub_inbox_like', array( self::class, 'incoming' ), 10, 2 );
+ \add_action( 'activitypub_handled_outbox_like', array( self::class, 'outgoing' ), 10, 4 );
\add_filter( 'activitypub_get_outbox_activity', array( self::class, 'outbox_activity' ) );
}
/**
- * Handles "Like" requests.
+ * Handle incoming "Like" requests from remote actors.
*
* @param array $like The Activity array.
* @param int|int[] $user_ids The user ID(s).
*/
- public static function handle_like( $like, $user_ids ) {
+ public static function incoming( $like, $user_ids ) {
if ( ! Comment::is_comment_type_enabled( 'like' ) ) {
return;
}
@@ -67,7 +67,7 @@ public static function handle_like( $like, $user_ids ) {
}
/**
- * Handle outbox "Like" activities (C2S).
+ * Handle outgoing "Like" activities from local actors.
*
* Records a like from the local user on remote content.
*
@@ -76,7 +76,7 @@ public static function handle_like( $like, $user_ids ) {
* @param \Activitypub\Activity\Activity $activity The Activity object.
* @param int $outbox_id The outbox post ID.
*/
- public static function handle_outbox_like( $data, $user_id, $activity, $outbox_id ) {
+ public static function outgoing( $data, $user_id, $activity, $outbox_id ) {
$object_url = object_to_uri( $data['object'] ?? '' );
if ( empty( $object_url ) ) {
@@ -84,7 +84,7 @@ public static function handle_outbox_like( $data, $user_id, $activity, $outbox_i
}
/**
- * Fires after a Like activity has been sent via C2S.
+ * Fires after an outgoing Like activity has been processed.
*
* @param string $object_url The URL of the liked object.
* @param array $data The activity data.
@@ -107,4 +107,34 @@ public static function outbox_activity( $activity ) {
return $activity;
}
+
+ /**
+ * Handle "Like" requests.
+ *
+ * @deprecated unreleased Use Like::incoming() instead.
+ *
+ * @param array $like The Activity array.
+ * @param int|int[] $user_ids The user ID(s).
+ */
+ public static function handle_like( $like, $user_ids ) {
+ \_deprecated_function( __METHOD__, 'unreleased', 'Like::incoming()' );
+
+ return self::incoming( $like, $user_ids );
+ }
+
+ /**
+ * Handle outbox "Like" activities.
+ *
+ * @deprecated unreleased Use Like::outgoing() instead.
+ *
+ * @param array $data The activity data array.
+ * @param int $user_id The user ID.
+ * @param \Activitypub\Activity\Activity $activity The Activity object.
+ * @param int $outbox_id The outbox post ID.
+ */
+ public static function handle_outbox_like( $data, $user_id, $activity, $outbox_id ) {
+ \_deprecated_function( __METHOD__, 'unreleased', 'Like::outgoing()' );
+
+ return self::outgoing( $data, $user_id, $activity, $outbox_id );
+ }
}
diff --git a/includes/handler/class-undo.php b/includes/handler/class-undo.php
index d280b7f635..a920a98850 100644
--- a/includes/handler/class-undo.php
+++ b/includes/handler/class-undo.php
@@ -21,18 +21,18 @@ class Undo {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
- \add_action( 'activitypub_inbox_undo', array( self::class, 'handle_undo' ), 10, 2 );
- \add_action( 'activitypub_handled_outbox_undo', array( self::class, 'handle_outbox_undo' ), 10, 4 );
+ \add_action( 'activitypub_inbox_undo', array( self::class, 'incoming' ), 10, 2 );
+ \add_action( 'activitypub_handled_outbox_undo', array( self::class, 'outgoing' ), 10, 4 );
\add_action( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 );
}
/**
- * Handle "Unfollow" requests.
+ * Handle incoming "Undo" requests from remote actors.
*
* @param array $activity The JSON "Undo" Activity.
* @param int|int[]|null $user_ids The user ID(s).
*/
- public static function handle_undo( $activity, $user_ids ) {
+ public static function incoming( $activity, $user_ids ) {
$success = false;
$result = Inbox_Collection::undo( object_to_uri( $activity['object'] ) );
@@ -87,7 +87,7 @@ public static function validate_object( $valid, $param, $request ) {
}
/**
- * Handle outbox "Undo" activities (C2S).
+ * Handle outgoing "Undo" activities from local actors.
*
* Handles Undo Follow (unfollow) activities.
*
@@ -96,7 +96,7 @@ public static function validate_object( $valid, $param, $request ) {
* @param \Activitypub\Activity\Activity $activity The Activity object.
* @param int $outbox_id The outbox post ID.
*/
- public static function handle_outbox_undo( $data, $user_id, $activity, $outbox_id ) {
+ public static function outgoing( $data, $user_id, $activity, $outbox_id ) {
$object = $data['object'] ?? array();
if ( ! \is_array( $object ) ) {
@@ -129,7 +129,7 @@ public static function handle_outbox_undo( $data, $user_id, $activity, $outbox_i
\delete_post_meta( $remote_actor->ID, Following::PENDING_META_KEY, $user_id );
/**
- * Fires after an Undo Follow activity has been sent via C2S.
+ * Fires after an outgoing Undo Follow activity has been processed.
*
* @param int $remote_actor_id The remote actor post ID.
* @param array $data The activity data.
@@ -138,4 +138,34 @@ public static function handle_outbox_undo( $data, $user_id, $activity, $outbox_i
*/
\do_action( 'activitypub_outbox_undo_follow_sent', $remote_actor->ID, $data, $user_id, $outbox_id );
}
+
+ /**
+ * Handle "Undo" requests.
+ *
+ * @deprecated unreleased Use Undo::incoming() instead.
+ *
+ * @param array $activity The JSON "Undo" Activity.
+ * @param int|int[]|null $user_ids The user ID(s).
+ */
+ public static function handle_undo( $activity, $user_ids ) {
+ \_deprecated_function( __METHOD__, 'unreleased', 'Undo::incoming()' );
+
+ return self::incoming( $activity, $user_ids );
+ }
+
+ /**
+ * Handle outbox "Undo" activities.
+ *
+ * @deprecated unreleased Use Undo::outgoing() instead.
+ *
+ * @param array $data The activity data array.
+ * @param int $user_id The user ID.
+ * @param \Activitypub\Activity\Activity $activity The Activity object.
+ * @param int $outbox_id The outbox post ID.
+ */
+ public static function handle_outbox_undo( $data, $user_id, $activity, $outbox_id ) {
+ \_deprecated_function( __METHOD__, 'unreleased', 'Undo::outgoing()' );
+
+ return self::outgoing( $data, $user_id, $activity, $outbox_id );
+ }
}
diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php
index 19803a69f6..fc22d302e4 100644
--- a/includes/handler/class-update.php
+++ b/includes/handler/class-update.php
@@ -22,18 +22,18 @@ class Update {
* Initialize the class, registering WordPress hooks.
*/
public static function init() {
- \add_action( 'activitypub_handled_inbox_update', array( self::class, 'handle_update' ), 10, 3 );
- \add_action( 'activitypub_handled_outbox_update', array( self::class, 'handle_outbox_update' ), 10, 4 );
+ \add_action( 'activitypub_handled_inbox_update', array( self::class, 'incoming' ), 10, 3 );
+ \add_action( 'activitypub_handled_outbox_update', array( self::class, 'outgoing' ), 10, 4 );
}
/**
- * Handle "Update" requests.
+ * Handle incoming "Update" requests from remote actors.
*
* @param array $activity The Activity object.
* @param int[] $user_ids The user IDs. Always null for Update activities.
* @param \Activitypub\Activity\Activity $activity_object The activity object. Default null.
*/
- public static function handle_update( $activity, $user_ids, $activity_object ) {
+ public static function incoming( $activity, $user_ids, $activity_object ) {
$object_type = $activity['object']['type'] ?? '';
switch ( $object_type ) {
@@ -105,7 +105,7 @@ public static function update_object( $activity, $user_ids, $activity_object ) {
// There is no object to update, try to trigger create instead.
if ( ! $updated ) {
- return Create::handle_create( $activity, $user_ids, $activity_object );
+ return Create::incoming( $activity, $user_ids, $activity_object );
}
$success = ( $result && ! \is_wp_error( $result ) );
@@ -149,7 +149,7 @@ public static function update_actor( $activity, $user_ids ) {
}
/**
- * Handle outbox "Update" activities (C2S).
+ * Handle outgoing "Update" activities from local actors.
*
* Updates a WordPress post from the ActivityPub object.
*
@@ -158,7 +158,7 @@ public static function update_actor( $activity, $user_ids ) {
* @param \Activitypub\Activity\Activity $activity The Activity object.
* @param int $outbox_id The outbox post ID.
*/
- public static function handle_outbox_update( $data, $user_id, $activity, $outbox_id ) {
+ public static function outgoing( $data, $user_id, $activity, $outbox_id ) {
$object = $data['object'] ?? array();
if ( ! \is_array( $object ) ) {
@@ -214,7 +214,7 @@ public static function handle_outbox_update( $data, $user_id, $activity, $outbox
}
/**
- * Fires after a post has been updated from a C2S Update activity.
+ * Fires after a post has been updated from an outgoing Update activity.
*
* @param int $post_id The updated post ID.
* @param array $data The activity data.
@@ -223,4 +223,35 @@ public static function handle_outbox_update( $data, $user_id, $activity, $outbox
*/
\do_action( 'activitypub_outbox_updated_post', $post_id, $data, $user_id, $outbox_id );
}
+
+ /**
+ * Handle "Update" requests.
+ *
+ * @deprecated unreleased Use Update::incoming() instead.
+ *
+ * @param array $activity The Activity object.
+ * @param int[] $user_ids The user IDs.
+ * @param \Activitypub\Activity\Activity $activity_object The activity object.
+ */
+ public static function handle_update( $activity, $user_ids, $activity_object ) {
+ \_deprecated_function( __METHOD__, 'unreleased', 'Update::incoming()' );
+
+ return self::incoming( $activity, $user_ids, $activity_object );
+ }
+
+ /**
+ * Handle outbox "Update" activities.
+ *
+ * @deprecated unreleased Use Update::outgoing() instead.
+ *
+ * @param array $data The activity data array.
+ * @param int $user_id The user ID.
+ * @param \Activitypub\Activity\Activity $activity The Activity object.
+ * @param int $outbox_id The outbox post ID.
+ */
+ public static function handle_outbox_update( $data, $user_id, $activity, $outbox_id ) {
+ \_deprecated_function( __METHOD__, 'unreleased', 'Update::outgoing()' );
+
+ return self::outgoing( $data, $user_id, $activity, $outbox_id );
+ }
}
diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php
index d41e48d98e..0a6476cfbc 100644
--- a/includes/rest/class-outbox-controller.php
+++ b/includes/rest/class-outbox-controller.php
@@ -15,7 +15,6 @@
use Activitypub\OAuth\Server as OAuth_Server;
use function Activitypub\add_to_outbox;
-use function Activitypub\camel_to_snake_case;
use function Activitypub\get_masked_wp_version;
use function Activitypub\get_rest_url_by_path;
@@ -366,11 +365,10 @@ public function create_item_permissions_check( $request ) {
}
/**
- * Create an item in the outbox (C2S).
+ * Create an item in the outbox.
*
- * Follows the same pattern as the Inbox controller:
- * 1. Store the activity in the outbox
- * 2. Trigger action hooks for handlers to process
+ * Fires handlers via filter to process the activity. Handlers are responsible
+ * for calling add_to_outbox() and returning the outbox_id.
*
* @param \WP_REST_Request $request Full details about the request.
* @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error on failure.
@@ -403,16 +401,38 @@ public function create_item( $request ) {
$data = $this->wrap_in_create( $data, $user );
}
- // Ensure the object has an ID (required for outbox storage).
- $data = $this->ensure_object_id( $data, $user );
-
- $activity_type = camel_to_snake_case( $data['type'] ?? '' );
-
// Determine visibility from addressing.
$visibility = $this->determine_visibility( $data );
- // Add to outbox - this handles storage and triggers federation.
- $outbox_id = add_to_outbox( $data, null, $user_id, $visibility );
+ $type = \strtolower( $data['type'] ?? 'create' );
+
+ /**
+ * Filters the activity to add to outbox.
+ *
+ * Handlers can process the activity and return:
+ * - int: The outbox post ID (handler called add_to_outbox)
+ * - WP_Error: Stop processing and return error
+ * - Other: No handler processed the activity (fallback to default)
+ *
+ * @param array $data The activity data.
+ * @param int $user_id The user ID.
+ * @param string $visibility Content visibility.
+ */
+ $result = \apply_filters( 'activitypub_outbox_' . $type, $data, $user_id, $visibility );
+
+ if ( \is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ // If handler returned an outbox ID, use it.
+ if ( \is_int( $result ) ) {
+ $outbox_id = $result;
+ } else {
+ // Default handling.
+ $data = \is_array( $result ) ? $result : $data;
+ $data = $this->ensure_object_id( $data, $user );
+ $outbox_id = add_to_outbox( $data, null, $user_id, $visibility );
+ }
if ( ! $outbox_id || \is_wp_error( $outbox_id ) ) {
return new \WP_Error(
@@ -422,52 +442,9 @@ public function create_item( $request ) {
);
}
- // Get the stored activity for hooks.
+ // Get the stored activity.
$activity = Outbox::get_activity( $outbox_id );
- /**
- * Fires for each outbox activity.
- *
- * @param array $data The activity data array.
- * @param int $user_id The user ID.
- * @param string $type The activity type (snake_case).
- * @param \Activitypub\Activity\Activity $activity The Activity object.
- */
- \do_action( 'activitypub_outbox', $data, $user_id, $activity_type, $activity );
-
- /**
- * Fires for specific outbox activity types.
- *
- * The dynamic portion of the hook name, `$activity_type`, refers to the
- * activity type in snake_case (e.g., 'create', 'update', 'delete', 'like').
- *
- * @param array $data The activity data array.
- * @param int $user_id The user ID.
- * @param \Activitypub\Activity\Activity $activity The Activity object.
- */
- \do_action( 'activitypub_outbox_' . $activity_type, $data, $user_id, $activity );
-
- /**
- * Fires after an outbox activity has been stored.
- *
- * @param array $data The activity data array.
- * @param int $user_id The user ID.
- * @param string $type The activity type (snake_case).
- * @param \Activitypub\Activity\Activity $activity The Activity object.
- * @param int $outbox_id The outbox post ID.
- */
- \do_action( 'activitypub_handled_outbox', $data, $user_id, $activity_type, $activity, $outbox_id );
-
- /**
- * Fires after a specific outbox activity type has been stored.
- *
- * @param array $data The activity data array.
- * @param int $user_id The user ID.
- * @param \Activitypub\Activity\Activity $activity The Activity object.
- * @param int $outbox_id The outbox post ID.
- */
- \do_action( 'activitypub_handled_outbox_' . $activity_type, $data, $user_id, $activity, $outbox_id );
-
if ( \is_wp_error( $activity ) ) {
return $activity;
}
diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php
index e8ea1c32b0..171cf117ca 100644
--- a/includes/scheduler/class-post.php
+++ b/includes/scheduler/class-post.php
@@ -101,6 +101,12 @@ public static function triage( $post_id, $post, $update, $post_before ) {
return;
}
+ // If the post was already federated and this is a Create, skip.
+ // The outbox controller already added it to the outbox.
+ if ( ACTIVITYPUB_OBJECT_STATE_FEDERATED === $object_status && 'Create' === $type ) {
+ return;
+ }
+
// If the post was never federated before, it should be a Create activity.
if ( empty( $object_status ) && 'Update' === $type ) {
$type = 'Create';
From 744fbee4e6c66ad4851228dfab3a05d1039621ba Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 1 Feb 2026 18:31:14 +0100
Subject: [PATCH 012/105] Simplify C2S outbox flow with synchronous
add_to_outbox
- Remove async scheduling from Post scheduler, call add_to_outbox directly
- Create handler returns WP_Post instead of calling add_to_outbox
- Add Outbox::get_by_object_id() to find outbox items by object ID and type
- Controller handles WP_Post return from handlers and uses outbox_item directly
---
includes/collection/class-outbox.php | 31 ++++++++++++++++
includes/handler/class-create.php | 10 ++----
includes/rest/class-outbox-controller.php | 24 +++++++------
includes/scheduler/class-post.php | 43 ++---------------------
4 files changed, 49 insertions(+), 59 deletions(-)
diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php
index 19a70c4d98..06753cca4d 100644
--- a/includes/collection/class-outbox.php
+++ b/includes/collection/class-outbox.php
@@ -190,6 +190,37 @@ public static function undo( $outbox_item ) {
return add_to_outbox( $activity, $type, $outbox_item->post_author, $visibility );
}
+ /**
+ * Get an outbox item by object ID and activity type.
+ *
+ * @param string $object_id The ActivityPub object ID.
+ * @param string $activity_type The activity type (Create, Update, etc.).
+ *
+ * @return \WP_Post|null The outbox item or null if not found.
+ */
+ public static function get_by_object_id( $object_id, $activity_type ) {
+ $outbox_items = \get_posts(
+ array(
+ 'post_type' => self::POST_TYPE,
+ 'post_status' => 'any',
+ 'posts_per_page' => 1,
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ 'meta_query' => array(
+ array(
+ 'key' => '_activitypub_object_id',
+ 'value' => $object_id,
+ ),
+ array(
+ 'key' => '_activitypub_activity_type',
+ 'value' => $activity_type,
+ ),
+ ),
+ )
+ );
+
+ return ! empty( $outbox_items ) ? $outbox_items[0] : null;
+ }
+
/**
* Get an outbox item by its GUID.
*
diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php
index 228efb4c08..ef344a8c32 100644
--- a/includes/handler/class-create.php
+++ b/includes/handler/class-create.php
@@ -11,7 +11,6 @@
use Activitypub\Collection\Posts;
use Activitypub\Tombstone;
-use function Activitypub\add_to_outbox;
use function Activitypub\get_activity_visibility;
use function Activitypub\get_content_visibility;
use function Activitypub\is_activity_reply;
@@ -175,13 +174,13 @@ private static function incoming_post( $activity, $user_ids ) {
/**
* Handle outgoing post from local actor.
*
- * Creates a WordPress post and adds to outbox for federation.
+ * Creates a WordPress post. The scheduler will add it to the outbox.
*
* @param array $activity The activity data.
* @param int $user_id The local user ID.
* @param string|null $visibility Content visibility.
*
- * @return int|\WP_Error The outbox ID on success, WP_Error on failure.
+ * @return \WP_Post|\WP_Error The created post on success, WP_Error on failure.
*/
private static function outgoing_post( $activity, $user_id, $visibility ) {
$object = $activity['object'] ?? array();
@@ -210,8 +209,6 @@ private static function outgoing_post( $activity, $user_id, $visibility ) {
'post_type' => 'post',
'meta_input' => array(
'activitypub_content_visibility' => $visibility,
- // Mark the post as federated to prevent the scheduler from also adding it to outbox.
- 'activitypub_status' => ACTIVITYPUB_OBJECT_STATE_FEDERATED,
),
);
@@ -233,8 +230,7 @@ private static function outgoing_post( $activity, $user_id, $visibility ) {
*/
\do_action( 'activitypub_outbox_created_post', $post_id, $activity, $user_id, $visibility );
- // Add to outbox and return the outbox ID.
- return add_to_outbox( $post, 'Create', $user_id, $visibility );
+ return $post;
}
/**
diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php
index 0a6476cfbc..67f58073f7 100644
--- a/includes/rest/class-outbox-controller.php
+++ b/includes/rest/class-outbox-controller.php
@@ -410,7 +410,7 @@ public function create_item( $request ) {
* Filters the activity to add to outbox.
*
* Handlers can process the activity and return:
- * - int: The outbox post ID (handler called add_to_outbox)
+ * - WP_Post: A WordPress post was created (scheduler adds to outbox)
* - WP_Error: Stop processing and return error
* - Other: No handler processed the activity (fallback to default)
*
@@ -424,17 +424,19 @@ public function create_item( $request ) {
return $result;
}
- // If handler returned an outbox ID, use it.
- if ( \is_int( $result ) ) {
- $outbox_id = $result;
+ // If handler returned a WP_Post, the scheduler already added it to outbox.
+ if ( $result instanceof \WP_Post ) {
+ $object_id = \Activitypub\get_post_id( $result->ID );
+ $activity_type = \ucfirst( $data['type'] ?? 'Create' );
+ $outbox_item = Outbox::get_by_object_id( $object_id, $activity_type );
} else {
- // Default handling.
- $data = \is_array( $result ) ? $result : $data;
- $data = $this->ensure_object_id( $data, $user );
- $outbox_id = add_to_outbox( $data, null, $user_id, $visibility );
+ // Default handling for raw activities.
+ $data = \is_array( $result ) ? $result : $data;
+ $data = $this->ensure_object_id( $data, $user );
+ $outbox_item = \get_post( add_to_outbox( $data, null, $user_id, $visibility ) );
}
- if ( ! $outbox_id || \is_wp_error( $outbox_id ) ) {
+ if ( ! $outbox_item ) {
return new \WP_Error(
'activitypub_outbox_error',
\__( 'Failed to add activity to outbox.', 'activitypub' ),
@@ -443,7 +445,7 @@ public function create_item( $request ) {
}
// Get the stored activity.
- $activity = Outbox::get_activity( $outbox_id );
+ $activity = Outbox::get_activity( $outbox_item );
if ( \is_wp_error( $activity ) ) {
return $activity;
@@ -453,7 +455,7 @@ public function create_item( $request ) {
// Return 201 Created with Location header.
$response = new \WP_REST_Response( $result, 201 );
- $response->header( 'Location', $result['id'] ?? \get_the_guid( $outbox_id ) );
+ $response->header( 'Location', $result['id'] ?? $outbox_item->guid );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
return $response;
diff --git a/includes/scheduler/class-post.php b/includes/scheduler/class-post.php
index 171cf117ca..236d5ea0a1 100644
--- a/includes/scheduler/class-post.php
+++ b/includes/scheduler/class-post.php
@@ -27,9 +27,6 @@ public static function init() {
// Post transitions.
\add_action( 'wp_after_insert_post', array( self::class, 'triage' ), 33, 4 );
- // Async handler for add_to_outbox.
- \add_action( 'activitypub_add_to_outbox', array( self::class, 'add_to_outbox' ), 10, 3 );
-
// Attachment transitions.
\add_action( 'add_attachment', array( self::class, 'transition_attachment_status' ) );
\add_action( 'edit_attachment', array( self::class, 'transition_attachment_status' ) );
@@ -117,37 +114,7 @@ public static function triage( $post_id, $post, $update, $post_before ) {
$type = 'Delete';
}
- // Schedule async add to outbox to avoid blocking post save.
- $scheduled = \wp_schedule_single_event( time(), 'activitypub_add_to_outbox', array( $post_id, $type, $post->post_author ) );
-
- // Fall back to synchronous execution if scheduling fails (e.g., in tests or when cron is disabled).
- if ( true !== $scheduled ) {
- add_to_outbox( $post, $type, $post->post_author );
- }
- }
-
- /**
- * Async handler for adding a post to the outbox.
- *
- * This runs asynchronously via WP Cron to avoid blocking the post save process.
- *
- * @param int $post_id Post ID.
- * @param string $type Activity type (Create, Update, Delete).
- * @param int $user_id User ID.
- */
- public static function add_to_outbox( $post_id, $type, $user_id ) {
- $post = \get_post( $post_id );
-
- if ( ! $post ) {
- return;
- }
-
- // Re-validate that the post is still eligible for federation.
- if ( is_post_disabled( $post ) ) {
- return;
- }
-
- add_to_outbox( $post, $type, $user_id );
+ add_to_outbox( $post, $type, $post->post_author );
}
/**
@@ -188,13 +155,7 @@ public static function transition_attachment_status( $post_id ) {
return;
}
- // Schedule async add to outbox to avoid blocking attachment save.
- $scheduled = \wp_schedule_single_event( time(), 'activitypub_add_to_outbox', array( $post_id, $type, $post->post_author ) );
-
- // Fall back to synchronous execution if scheduling fails (e.g., in tests or when cron is disabled).
- if ( true !== $scheduled ) {
- add_to_outbox( $post, $type, $post->post_author );
- }
+ add_to_outbox( $post, $type, $post->post_author );
}
/**
From 63055f1eeb50f6c9dc1cbb061f37b144d82390f8 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 1 Feb 2026 18:54:47 +0100
Subject: [PATCH 013/105] Improve post lookup and OAuth handling for C2S
Update delete and update handlers to first resolve posts by permalink for C2S-created posts, falling back to GUID lookup for remote posts. Enhance OAuth server to respect previous auth errors and only process OAuth if C2S is enabled. Add type safety for user_id in REST controllers. Update template variable documentation and add PHPCS ignore comment in token class.
---
includes/handler/class-delete.php | 9 ++++++++-
includes/handler/class-update.php | 9 ++++++++-
includes/oauth/class-server.php | 10 ++++++++++
includes/oauth/class-token.php | 2 +-
includes/rest/class-inbox-controller.php | 3 ++-
includes/rest/class-outbox-controller.php | 3 ++-
templates/oauth-authorize.php | 22 ++++++++++++----------
7 files changed, 43 insertions(+), 15 deletions(-)
diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php
index 6081181e4c..d66bc82b96 100644
--- a/includes/handler/class-delete.php
+++ b/includes/handler/class-delete.php
@@ -376,7 +376,14 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) {
}
// Find the post by its ActivityPub ID.
- $post = Posts::get_by_guid( $object_id );
+ // First try to find a local post by permalink (for C2S-created posts).
+ $post_id = \url_to_postid( $object_id );
+ $post = $post_id ? \get_post( $post_id ) : null;
+
+ // Fall back to Posts collection for remote posts (ap_post type).
+ if ( ! $post instanceof \WP_Post ) {
+ $post = Posts::get_by_guid( $object_id );
+ }
if ( ! $post instanceof \WP_Post ) {
return;
diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php
index fc22d302e4..d80ff3c9ad 100644
--- a/includes/handler/class-update.php
+++ b/includes/handler/class-update.php
@@ -179,7 +179,14 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) {
}
// Find the post by its ActivityPub ID.
- $post = Posts::get_by_guid( $object_id );
+ // First try to find a local post by permalink (for C2S-created posts).
+ $post_id = \url_to_postid( $object_id );
+ $post = $post_id ? \get_post( $post_id ) : null;
+
+ // Fall back to Posts collection for remote posts (ap_post type).
+ if ( ! $post instanceof \WP_Post ) {
+ $post = Posts::get_by_guid( $object_id );
+ }
if ( ! $post instanceof \WP_Post ) {
return;
diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php
index c37422acf8..0718e7202f 100644
--- a/includes/oauth/class-server.php
+++ b/includes/oauth/class-server.php
@@ -48,6 +48,11 @@ public static function authenticate_oauth( $result ) {
return $result;
}
+ // If a previous auth filter returned an error, respect it.
+ if ( \is_wp_error( $result ) ) {
+ return $result;
+ }
+
// Check for Bearer token.
$token = self::get_bearer_token();
@@ -56,6 +61,11 @@ public static function authenticate_oauth( $result ) {
return $result;
}
+ // Only process OAuth if C2S is enabled.
+ if ( ! self::is_c2s_enabled() ) {
+ return $result;
+ }
+
// Validate the token.
$validated = Token::validate( $token );
diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php
index 85b43b6eef..cc44ec3822 100644
--- a/includes/oauth/class-token.php
+++ b/includes/oauth/class-token.php
@@ -364,7 +364,7 @@ public static function get_all_for_user( $user_id ) {
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;
+ $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;
}
}
diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php
index 2c3bc5cecd..dd668197e2 100644
--- a/includes/rest/class-inbox-controller.php
+++ b/includes/rest/class-inbox-controller.php
@@ -242,7 +242,8 @@ public function get_items_permissions_check( $request ) {
}
// Verify the token belongs to the requested user.
- $token = OAuth_Server::get_current_token();
+ $token = OAuth_Server::get_current_token();
+ $user_id = absint( $user_id );
if ( ! $token || $token->get_user_id() !== $user_id ) {
return new \WP_Error(
diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php
index 67f58073f7..b959a7a87b 100644
--- a/includes/rest/class-outbox-controller.php
+++ b/includes/rest/class-outbox-controller.php
@@ -17,6 +17,7 @@
use function Activitypub\add_to_outbox;
use function Activitypub\get_masked_wp_version;
use function Activitypub\get_rest_url_by_path;
+use function Activitypub\object_to_uri;
/**
* ActivityPub Outbox Controller.
@@ -350,7 +351,7 @@ public function create_item_permissions_check( $request ) {
}
// Token user must match actor in URL.
- $user_id = $request->get_param( 'user_id' );
+ $user_id = absint( $request->get_param( 'user_id' ) );
$token = OAuth_Server::get_current_token();
if ( ! $token || $token->get_user_id() !== $user_id ) {
diff --git a/templates/oauth-authorize.php b/templates/oauth-authorize.php
index 145fee0251..13324adb5d 100644
--- a/templates/oauth-authorize.php
+++ b/templates/oauth-authorize.php
@@ -4,19 +4,21 @@
*
* @package Activitypub
*
- * Variables available:
- * @var WP_User $current_user The current logged-in user.
- * @var array $scopes Array of requested scopes.
- * @var string $client_id The client ID.
- * @var string $client_name The client name.
- * @var string $redirect_uri The redirect URI.
- * @var string $state The state parameter.
- * @var string $code_challenge The PKCE code challenge.
+ * Variables available (passed via include from class-server.php):
+ * @var WP_User $current_user The current logged-in user.
+ * @var array $scopes Array of requested scopes.
+ * @var string $client_id The client ID.
+ * @var string $client_name The client name.
+ * @var string $redirect_uri The redirect URI.
+ * @var string $state The state parameter.
+ * @var string $code_challenge The PKCE code challenge.
* @var string $code_challenge_method The PKCE method.
- * @var string $form_url The form action URL.
- * @var string $scope The original scope string.
+ * @var string $form_url The form action URL.
+ * @var string $scope The original scope string.
*/
+// phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UndefinedVariable -- Variables passed via include.
+
use Activitypub\OAuth\Scope;
// Use WordPress login page header.
From c10894c739008457bc58303a0d2a1f5343aeff43 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Mon, 2 Feb 2026 08:36:14 +0100
Subject: [PATCH 014/105] Fix argument passed to send_to_inboxes in test
Pass the outbox item's ID instead of the object itself to the send_to_inboxes method in the test case. This aligns the test with the expected method signature.
---
tests/phpunit/tests/includes/class-test-dispatcher.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/phpunit/tests/includes/class-test-dispatcher.php b/tests/phpunit/tests/includes/class-test-dispatcher.php
index 1deee4b33d..6f3d8f67bd 100644
--- a/tests/phpunit/tests/includes/class-test-dispatcher.php
+++ b/tests/phpunit/tests/includes/class-test-dispatcher.php
@@ -127,7 +127,7 @@ public function test_send_to_inboxes_schedules_retry( $code, $message, $inboxes,
// Invoke the method.
try {
- $retries = $send_to_inboxes->invoke( null, $inboxes, $outbox_item ); // null for static methods.
+ $retries = $send_to_inboxes->invoke( null, $inboxes, $outbox_item->ID ); // null for static methods.
} catch ( \Exception $e ) {
$this->fail( 'Invoke failed: ' . $e->getMessage() );
}
From 76b2e028ac28b1be6661c89cb679dfbf5238cd50 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Mon, 2 Feb 2026 08:58:43 +0100
Subject: [PATCH 015/105] Add proxyUrl endpoint and enable C2S by default
- Add proxyUrl endpoint for C2S clients to fetch remote ActivityPub
objects through the server's HTTP Signatures
- Remove activitypub_enable_c2s option - C2S is now always enabled
- Remove settings field for C2S toggle from advanced settings
- Always include OAuth and C2S endpoints in actor profiles
- Add security checks for proxy: HTTPS-only, block private networks
- Use Remote_Actors::fetch_by_various() for efficient actor caching
---
activitypub.php | 1 +
includes/class-options.php | 10 -
includes/model/class-blog.php | 15 +-
includes/model/class-user.php | 15 +-
includes/oauth/class-server.php | 20 +-
includes/rest/class-inbox-controller.php | 9 -
includes/rest/class-oauth-controller.php | 36 ----
includes/rest/class-outbox-controller.php | 9 -
includes/rest/class-proxy-controller.php | 197 ++++++++++++++++++
.../class-advanced-settings-fields.php | 40 ----
.../rest/class-test-proxy-controller.php | 186 +++++++++++++++++
11 files changed, 398 insertions(+), 140 deletions(-)
create mode 100644 includes/rest/class-proxy-controller.php
create mode 100644 tests/phpunit/tests/rest/class-test-proxy-controller.php
diff --git a/activitypub.php b/activitypub.php
index 2f722ae9d2..33279c69f8 100644
--- a/activitypub.php
+++ b/activitypub.php
@@ -72,6 +72,7 @@ function rest_init() {
// Load OAuth REST endpoints.
( new Rest\OAuth_Controller() )->register_routes();
+ ( new Rest\Proxy_Controller() )->register_routes();
}
\add_action( 'rest_api_init', __NAMESPACE__ . '\rest_init' );
diff --git a/includes/class-options.php b/includes/class-options.php
index 42f3714b03..54b04da7c1 100644
--- a/includes/class-options.php
+++ b/includes/class-options.php
@@ -330,16 +330,6 @@ public static function register_settings() {
)
);
- \register_setting(
- 'activitypub_advanced',
- 'activitypub_enable_c2s',
- array(
- 'type' => 'boolean',
- 'description' => 'Enable Client-to-Server (C2S) support for third-party ActivityPub clients.',
- 'default' => false,
- )
- );
-
/*
* Options Group: activitypub_blog
*/
diff --git a/includes/model/class-blog.php b/includes/model/class-blog.php
index 7dedf2377e..9e4631caca 100644
--- a/includes/model/class-blog.php
+++ b/includes/model/class-blog.php
@@ -396,17 +396,12 @@ public function get_following() {
* @return string[]|null The endpoints.
*/
public function get_endpoints() {
- $endpoints = array(
- 'sharedInbox' => get_rest_url_by_path( 'inbox' ),
+ return array(
+ 'sharedInbox' => get_rest_url_by_path( 'inbox' ),
+ 'oauthAuthorizationEndpoint' => get_rest_url_by_path( 'oauth/authorize' ),
+ 'oauthTokenEndpoint' => get_rest_url_by_path( 'oauth/token' ),
+ 'proxyUrl' => get_rest_url_by_path( 'proxy' ),
);
-
- // Add OAuth endpoints if C2S is enabled.
- if ( \get_option( 'activitypub_enable_c2s', false ) ) {
- $endpoints['oauthAuthorizationEndpoint'] = get_rest_url_by_path( 'oauth/authorize' );
- $endpoints['oauthTokenEndpoint'] = get_rest_url_by_path( 'oauth/token' );
- }
-
- return $endpoints;
}
/**
diff --git a/includes/model/class-user.php b/includes/model/class-user.php
index 57ae3b127c..8f85ea5532 100644
--- a/includes/model/class-user.php
+++ b/includes/model/class-user.php
@@ -317,17 +317,12 @@ public function get_featured_tags() {
* @return string[]|null The endpoints.
*/
public function get_endpoints() {
- $endpoints = array(
- 'sharedInbox' => get_rest_url_by_path( 'inbox' ),
+ return array(
+ 'sharedInbox' => get_rest_url_by_path( 'inbox' ),
+ 'oauthAuthorizationEndpoint' => get_rest_url_by_path( 'oauth/authorize' ),
+ 'oauthTokenEndpoint' => get_rest_url_by_path( 'oauth/token' ),
+ 'proxyUrl' => get_rest_url_by_path( 'proxy' ),
);
-
- // Add OAuth endpoints if C2S is enabled.
- if ( \get_option( 'activitypub_enable_c2s', false ) ) {
- $endpoints['oauthAuthorizationEndpoint'] = get_rest_url_by_path( 'oauth/authorize' );
- $endpoints['oauthTokenEndpoint'] = get_rest_url_by_path( 'oauth/token' );
- }
-
- return $endpoints;
}
/**
diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php
index 0718e7202f..23e5382537 100644
--- a/includes/oauth/class-server.php
+++ b/includes/oauth/class-server.php
@@ -61,11 +61,6 @@ public static function authenticate_oauth( $result ) {
return $result;
}
- // Only process OAuth if C2S is enabled.
- if ( ! self::is_c2s_enabled() ) {
- return $result;
- }
-
// Validate the token.
$validated = Token::validate( $token );
@@ -222,10 +217,12 @@ public static function check_oauth_permission( $request, $scope = null ) {
/**
* Check if C2S (Client-to-Server) is enabled.
*
- * @return bool True if C2S is enabled.
+ * @deprecated C2S is now always enabled.
+ *
+ * @return bool Always returns true.
*/
public static function is_c2s_enabled() {
- return (bool) \get_option( 'activitypub_enable_c2s', false );
+ return true;
}
/**
@@ -276,15 +273,6 @@ public static function login_form_authorize() {
\auth_redirect();
}
- // Check if C2S is enabled.
- if ( ! self::is_c2s_enabled() ) {
- \wp_die(
- \esc_html__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ),
- \esc_html__( 'Authorization Error', 'activitypub' ),
- array( 'response' => 403 )
- );
- }
-
$request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
if ( 'GET' === $request_method ) {
diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php
index dd668197e2..9e71439acf 100644
--- a/includes/rest/class-inbox-controller.php
+++ b/includes/rest/class-inbox-controller.php
@@ -217,15 +217,6 @@ public function validate_user_id( $user_id ) {
* @return bool|\WP_Error True if authorized, WP_Error otherwise.
*/
public function get_items_permissions_check( $request ) {
- // Check if C2S is enabled.
- if ( ! OAuth_Server::is_c2s_enabled() ) {
- return new \WP_Error(
- 'activitypub_c2s_disabled',
- \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ),
- array( 'status' => 403 )
- );
- }
-
$user_id = $request->get_param( 'user_id' );
// Validate the user.
diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php
index c52b3d8f3b..c6d91561ee 100644
--- a/includes/rest/class-oauth-controller.php
+++ b/includes/rest/class-oauth-controller.php
@@ -200,15 +200,6 @@ public function register_routes() {
* @return \WP_REST_Response|\WP_Error
*/
public function authorize( \WP_REST_Request $request ) {
- // Check if C2S is enabled.
- if ( ! OAuth_Server::is_c2s_enabled() ) {
- return new \WP_Error(
- 'activitypub_c2s_disabled',
- \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ),
- array( 'status' => 403 )
- );
- }
-
$client_id = $request->get_param( 'client_id' );
$redirect_uri = $request->get_param( 'redirect_uri' );
$response_type = $request->get_param( 'response_type' );
@@ -282,15 +273,6 @@ public function authorize( \WP_REST_Request $request ) {
* @return \WP_REST_Response|\WP_Error
*/
public function authorize_submit( \WP_REST_Request $request ) {
- // Check if C2S is enabled.
- if ( ! OAuth_Server::is_c2s_enabled() ) {
- return new \WP_Error(
- 'activitypub_c2s_disabled',
- \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ),
- array( 'status' => 403 )
- );
- }
-
$client_id = $request->get_param( 'client_id' );
$redirect_uri = $request->get_param( 'redirect_uri' );
$scope = $request->get_param( 'scope' );
@@ -380,15 +362,6 @@ public function authorize_submit_permissions_check( \WP_REST_Request $request )
* @return \WP_REST_Response|\WP_Error
*/
public function token( \WP_REST_Request $request ) {
- // Check if C2S is enabled.
- if ( ! OAuth_Server::is_c2s_enabled() ) {
- return new \WP_Error(
- 'activitypub_c2s_disabled',
- \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ),
- array( 'status' => 403 )
- );
- }
-
$grant_type = $request->get_param( 'grant_type' );
$client_id = $request->get_param( 'client_id' );
@@ -509,15 +482,6 @@ public function introspect( \WP_REST_Request $request ) {
* @return \WP_REST_Response|\WP_Error
*/
public function register_client( \WP_REST_Request $request ) {
- // Check if C2S is enabled.
- if ( ! OAuth_Server::is_c2s_enabled() ) {
- return new \WP_Error(
- 'activitypub_c2s_disabled',
- \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ),
- array( 'status' => 403 )
- );
- }
-
// Check if dynamic registration is allowed.
if ( ! \apply_filters( 'activitypub_allow_dynamic_client_registration', true ) ) {
return new \WP_Error(
diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php
index b959a7a87b..d9713e7438 100644
--- a/includes/rest/class-outbox-controller.php
+++ b/includes/rest/class-outbox-controller.php
@@ -335,15 +335,6 @@ public function overload_total_items( $response, $request ) {
* @return bool|\WP_Error True if authorized, WP_Error otherwise.
*/
public function create_item_permissions_check( $request ) {
- // Check if C2S is enabled.
- if ( ! OAuth_Server::is_c2s_enabled() ) {
- return new \WP_Error(
- 'activitypub_c2s_disabled',
- \__( 'Client-to-Server (C2S) support is not enabled.', 'activitypub' ),
- array( 'status' => 403 )
- );
- }
-
// Must be authenticated via OAuth with 'write' scope.
$permission = OAuth_Server::check_oauth_permission( $request, Scope::WRITE );
if ( \is_wp_error( $permission ) ) {
diff --git a/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php
new file mode 100644
index 0000000000..6824d1c0c5
--- /dev/null
+++ b/includes/rest/class-proxy-controller.php
@@ -0,0 +1,197 @@
+namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'get_item' ),
+ 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'args' => array(
+ 'id' => array(
+ 'description' => 'The URI of the remote ActivityPub object to fetch.',
+ 'type' => 'string',
+ 'required' => true,
+ 'sanitize_callback' => 'sanitize_url',
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Check if the request has permission to use the proxy.
+ *
+ * @param \WP_REST_Request $request Full details about the request.
+ * @return true|\WP_Error True if the request has permission, WP_Error otherwise.
+ */
+ public function get_item_permissions_check( $request ) {
+ // Must be authenticated via OAuth with 'read' scope.
+ $permission = OAuth_Server::check_oauth_permission( $request, Scope::READ );
+ if ( \is_wp_error( $permission ) ) {
+ return $permission;
+ }
+
+ // Validate the URL to prevent abuse.
+ $url = $request->get_param( 'id' );
+
+ // Must be HTTPS.
+ if ( 'https' !== \wp_parse_url( $url, PHP_URL_SCHEME ) ) {
+ return new \WP_Error(
+ 'activitypub_invalid_url',
+ \__( 'Only HTTPS URLs are allowed.', 'activitypub' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ // Block local/private network addresses.
+ $host = \wp_parse_url( $url, PHP_URL_HOST );
+ if ( $this->is_private_host( $host ) ) {
+ return new \WP_Error(
+ 'activitypub_invalid_url',
+ \__( 'Private network addresses are not allowed.', 'activitypub' ),
+ array( 'status' => 400 )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Fetch a remote ActivityPub object via the proxy.
+ *
+ * @param \WP_REST_Request $request Full details about the request.
+ * @return \WP_REST_Response|\WP_Error Response object on success, WP_Error on failure.
+ */
+ public function get_item( $request ) {
+ $url = $request->get_param( 'id' );
+
+ // Try to fetch as an actor first using Remote_Actors which handles caching.
+ $post = Remote_Actors::fetch_by_various( $url );
+
+ if ( ! \is_wp_error( $post ) ) {
+ $actor = Remote_Actors::get_actor( $post );
+
+ if ( ! \is_wp_error( $actor ) ) {
+ $response = new \WP_REST_Response( $actor->to_array(), 200 );
+ $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
+
+ return $response;
+ }
+ }
+
+ // Fall back to fetching as a generic object.
+ $object = Http::get_remote_object( $url );
+
+ if ( \is_wp_error( $object ) ) {
+ return new \WP_Error(
+ 'activitypub_fetch_failed',
+ \__( 'Failed to fetch the remote object.', 'activitypub' ),
+ array( 'status' => 502 )
+ );
+ }
+
+ // If it's an actor, store it for future use.
+ if ( is_actor( $object ) ) {
+ Remote_Actors::upsert( $object );
+ }
+
+ $response = new \WP_REST_Response( $object, 200 );
+ $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
+
+ return $response;
+ }
+
+ /**
+ * Check if a host is a private/local network address.
+ *
+ * @param string $host The hostname to check.
+ * @return bool True if the host is private, false otherwise.
+ */
+ private function is_private_host( $host ) {
+ // Check for localhost.
+ if ( 'localhost' === $host || '127.0.0.1' === $host || '::1' === $host ) {
+ return true;
+ }
+
+ // Check for private IP ranges.
+ $ip = \gethostbyname( $host );
+ if ( $ip === $host ) {
+ // DNS resolution failed, allow it (will fail on fetch anyway).
+ return false;
+ }
+
+ // Use filter_var to check for private/reserved IPs.
+ if ( false === \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the schema for the proxy endpoint.
+ *
+ * @return array Schema array.
+ */
+ public function get_item_schema() {
+ return array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'proxy',
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => \__( 'The URI of the remote ActivityPub object.', 'activitypub' ),
+ 'type' => 'string',
+ 'format' => 'uri',
+ 'context' => array( 'view' ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/includes/wp-admin/class-advanced-settings-fields.php b/includes/wp-admin/class-advanced-settings-fields.php
index 20772f73c5..5efa7aff77 100644
--- a/includes/wp-admin/class-advanced-settings-fields.php
+++ b/includes/wp-admin/class-advanced-settings-fields.php
@@ -98,15 +98,6 @@ public static function register_advanced_fields() {
'activitypub_advanced_settings',
array( 'label_for' => 'activitypub_object_type' )
);
-
- \add_settings_field(
- 'activitypub_enable_c2s',
- \__( 'Client-to-Server (C2S)', 'activitypub' ),
- array( self::class, 'render_enable_c2s_field' ),
- 'activitypub_advanced_settings',
- 'activitypub_advanced_settings',
- array( 'label_for' => 'activitypub_enable_c2s' )
- );
}
/**
@@ -262,35 +253,4 @@ public static function render_object_type_field() {
-
-
-
-
-
-
-
- SWICG ActivityPub API specification, which is still under development. Some features may change in future versions.', 'activitypub' ),
- array(
- 'a' => array(
- 'href' => true,
- 'target' => true,
- ),
- )
- );
- ?>
-
- user->create( array( 'role' => 'author' ) );
+ \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' );
+ }
+
+ /**
+ * Set up test fixtures.
+ */
+ public function set_up() {
+ parent::set_up();
+
+ global $wp_rest_server;
+ $wp_rest_server = new \WP_REST_Server();
+ $this->server = $wp_rest_server;
+
+ ( new Proxy_Controller() )->register_routes();
+ }
+
+ /**
+ * Clean up test resources.
+ */
+ public static function tear_down_after_class() {
+ \wp_delete_user( self::$user_id );
+ parent::tear_down_after_class();
+ }
+
+ /**
+ * Test that the proxy route is registered.
+ *
+ * @covers ::register_routes
+ */
+ public function test_route_registered() {
+ $routes = $this->server->get_routes();
+ $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy', $routes );
+ }
+
+ /**
+ * Test that proxy rejects non-HTTPS URLs.
+ *
+ * @covers ::get_item_permissions_check
+ */
+ public function test_http_url_rejected() {
+ // Mock OAuth authentication.
+ $this->mock_oauth_auth();
+
+ $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' );
+ $request->set_body_params( array( 'id' => 'http://example.com/users/test' ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'activitypub_invalid_url', $response->get_data()['code'] );
+
+ $this->unmock_oauth_auth();
+ }
+
+ /**
+ * Test that proxy rejects localhost URLs.
+ *
+ * @covers ::get_item_permissions_check
+ */
+ public function test_localhost_rejected() {
+ // Mock OAuth authentication.
+ $this->mock_oauth_auth();
+
+ $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' );
+ $request->set_body_params( array( 'id' => 'https://localhost/users/test' ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 400, $response->get_status() );
+ $this->assertEquals( 'activitypub_invalid_url', $response->get_data()['code'] );
+
+ $this->unmock_oauth_auth();
+ }
+
+ /**
+ * Test proxy requires OAuth authentication.
+ *
+ * @covers ::get_item_permissions_check
+ */
+ public function test_requires_oauth() {
+ $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' );
+ $request->set_body_params( array( 'id' => 'https://example.com/users/test' ) );
+
+ $response = $this->server->dispatch( $request );
+
+ // Should fail with 401 or similar since no OAuth token is provided.
+ $this->assertNotEquals( 200, $response->get_status() );
+ }
+
+ /**
+ * Test successful proxy fetch of an actor.
+ *
+ * @covers ::get_item
+ */
+ public function test_successful_actor_fetch() {
+ // Mock OAuth authentication.
+ $this->mock_oauth_auth();
+
+ // Mock the HTTP response.
+ $actor_data = array(
+ '@context' => 'https://www.w3.org/ns/activitystreams',
+ 'type' => 'Person',
+ 'id' => 'https://example.com/users/test',
+ 'inbox' => 'https://example.com/users/test/inbox',
+ 'preferredUsername' => 'test',
+ 'name' => 'Test User',
+ );
+
+ \add_filter(
+ 'pre_http_request',
+ function () use ( $actor_data ) {
+ return array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => \wp_json_encode( $actor_data ),
+ 'headers' => array( 'content-type' => 'application/activity+json' ),
+ );
+ }
+ );
+
+ $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' );
+ $request->set_body_params( array( 'id' => 'https://example.com/users/test' ) );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertEquals( 200, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertEquals( 'Person', $data['type'] );
+ $this->assertEquals( 'https://example.com/users/test', $data['id'] );
+
+ $this->unmock_oauth_auth();
+ }
+
+ /**
+ * Mock OAuth authentication for testing.
+ */
+ private function mock_oauth_auth() {
+ \add_filter( 'activitypub_oauth_check_permission', '__return_true' );
+ }
+
+ /**
+ * Remove OAuth mock.
+ */
+ private function unmock_oauth_auth() {
+ \remove_filter( 'activitypub_oauth_check_permission', '__return_true' );
+ }
+}
From 60bc09d7c9897dbeea2938c740072ba2acbfd433 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Mon, 2 Feb 2026 09:10:51 +0100
Subject: [PATCH 016/105] Move OAuth verification to Server class
- Add verify_oauth_read() and verify_oauth_write() methods to Server
- Add verify_owner() to check token matches user_id parameter
- Simplify permission checks in Inbox, Outbox, and Proxy controllers
- Remove direct OAuth imports from controllers
---
includes/rest/class-inbox-controller.php | 28 ++---------
includes/rest/class-outbox-controller.php | 25 +++-------
includes/rest/class-proxy-controller.php | 10 ++--
includes/rest/class-server.php | 59 +++++++++++++++++++++++
4 files changed, 72 insertions(+), 50 deletions(-)
diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php
index 9e71439acf..0ec0b373a9 100644
--- a/includes/rest/class-inbox-controller.php
+++ b/includes/rest/class-inbox-controller.php
@@ -14,8 +14,6 @@
use Activitypub\Collection\Inbox;
use Activitypub\Http;
use Activitypub\Moderation;
-use Activitypub\OAuth\Scope;
-use Activitypub\OAuth\Server as OAuth_Server;
use function Activitypub\camel_to_snake_case;
use function Activitypub\extract_recipients_from_activity;
@@ -217,34 +215,14 @@ public function validate_user_id( $user_id ) {
* @return bool|\WP_Error True if authorized, WP_Error otherwise.
*/
public function get_items_permissions_check( $request ) {
- $user_id = $request->get_param( 'user_id' );
-
- // Validate the user.
- $user = Actors::get_by_id( $user_id );
- if ( \is_wp_error( $user ) ) {
- return $user;
- }
-
- // Validate OAuth token and scope.
- $result = OAuth_Server::check_oauth_permission( $request, Scope::READ );
-
+ // Verify OAuth with read scope.
+ $result = Server::verify_oauth_read( $request );
if ( \is_wp_error( $result ) ) {
return $result;
}
// Verify the token belongs to the requested user.
- $token = OAuth_Server::get_current_token();
- $user_id = absint( $user_id );
-
- if ( ! $token || $token->get_user_id() !== $user_id ) {
- return new \WP_Error(
- 'activitypub_unauthorized',
- \__( 'You can only read your own inbox.', 'activitypub' ),
- array( 'status' => 403 )
- );
- }
-
- return true;
+ return Server::verify_owner( $request );
}
/**
diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php
index d9713e7438..9a5d50d108 100644
--- a/includes/rest/class-outbox-controller.php
+++ b/includes/rest/class-outbox-controller.php
@@ -11,8 +11,6 @@
use Activitypub\Activity\Base_Object;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Outbox;
-use Activitypub\OAuth\Scope;
-use Activitypub\OAuth\Server as OAuth_Server;
use function Activitypub\add_to_outbox;
use function Activitypub\get_masked_wp_version;
@@ -335,25 +333,14 @@ public function overload_total_items( $response, $request ) {
* @return bool|\WP_Error True if authorized, WP_Error otherwise.
*/
public function create_item_permissions_check( $request ) {
- // Must be authenticated via OAuth with 'write' scope.
- $permission = OAuth_Server::check_oauth_permission( $request, Scope::WRITE );
- if ( \is_wp_error( $permission ) ) {
- return $permission;
- }
-
- // Token user must match actor in URL.
- $user_id = absint( $request->get_param( 'user_id' ) );
- $token = OAuth_Server::get_current_token();
-
- if ( ! $token || $token->get_user_id() !== $user_id ) {
- return new \WP_Error(
- 'activitypub_forbidden',
- \__( 'You can only post to your own outbox.', 'activitypub' ),
- array( 'status' => 403 )
- );
+ // Verify OAuth with write scope.
+ $result = Server::verify_oauth_write( $request );
+ if ( \is_wp_error( $result ) ) {
+ return $result;
}
- return true;
+ // Verify the token belongs to the requested user.
+ return Server::verify_owner( $request );
}
/**
diff --git a/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php
index 6824d1c0c5..a5f84e1595 100644
--- a/includes/rest/class-proxy-controller.php
+++ b/includes/rest/class-proxy-controller.php
@@ -12,8 +12,6 @@
use Activitypub\Collection\Remote_Actors;
use Activitypub\Http;
-use Activitypub\OAuth\Scope;
-use Activitypub\OAuth\Server as OAuth_Server;
use function Activitypub\is_actor;
@@ -71,10 +69,10 @@ public function register_routes() {
* @return true|\WP_Error True if the request has permission, WP_Error otherwise.
*/
public function get_item_permissions_check( $request ) {
- // Must be authenticated via OAuth with 'read' scope.
- $permission = OAuth_Server::check_oauth_permission( $request, Scope::READ );
- if ( \is_wp_error( $permission ) ) {
- return $permission;
+ // Verify OAuth with read scope.
+ $result = Server::verify_oauth_read( $request );
+ if ( \is_wp_error( $result ) ) {
+ return $result;
}
// Validate the URL to prevent abuse.
diff --git a/includes/rest/class-server.php b/includes/rest/class-server.php
index 3309eb5953..70a4f8a80f 100644
--- a/includes/rest/class-server.php
+++ b/includes/rest/class-server.php
@@ -7,6 +7,9 @@
namespace Activitypub\Rest;
+use Activitypub\Collection\Actors;
+use Activitypub\OAuth\Scope;
+use Activitypub\OAuth\Server as OAuth_Server;
use Activitypub\Signature;
use function Activitypub\use_authorized_fetch;
@@ -82,6 +85,62 @@ public static function verify_signature( $request ) {
return true;
}
+ /**
+ * Verify OAuth authentication with 'read' scope.
+ *
+ * Use this as a permission_callback for endpoints requiring OAuth read access.
+ *
+ * @param \WP_REST_Request $request The request object.
+ * @return bool|\WP_Error True if authorized, WP_Error otherwise.
+ */
+ public static function verify_oauth_read( $request ) {
+ return OAuth_Server::check_oauth_permission( $request, Scope::READ );
+ }
+
+ /**
+ * Verify OAuth authentication with 'write' scope.
+ *
+ * Use this as a permission_callback for endpoints requiring OAuth write access.
+ *
+ * @param \WP_REST_Request $request The request object.
+ * @return bool|\WP_Error True if authorized, WP_Error otherwise.
+ */
+ public static function verify_oauth_write( $request ) {
+ return OAuth_Server::check_oauth_permission( $request, Scope::WRITE );
+ }
+
+ /**
+ * Verify that the OAuth token belongs to the actor specified in the request.
+ *
+ * This checks that the user_id parameter matches the token's user.
+ * Should be called after verify_oauth_read or verify_oauth_write.
+ *
+ * @param \WP_REST_Request $request The request object.
+ * @return bool|\WP_Error True if the token user matches, WP_Error otherwise.
+ */
+ public static function verify_owner( $request ) {
+ $user_id = $request->get_param( 'user_id' );
+
+ // Validate the user exists.
+ $user = Actors::get_by_id( $user_id );
+ if ( \is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ // Verify the token belongs to this user.
+ $token = OAuth_Server::get_current_token();
+
+ if ( ! $token || $token->get_user_id() !== absint( $user_id ) ) {
+ return new \WP_Error(
+ 'activitypub_forbidden',
+ \__( 'You can only access your own resources.', 'activitypub' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ return true;
+ }
+
/**
* Callback function to validate incoming ActivityPub requests
*
From efd8a5c6c748f7440f1cd0dbd1254b67b5c142d5 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Mon, 2 Feb 2026 09:17:56 +0100
Subject: [PATCH 017/105] Add Verification trait for centralized auth checks
- Create trait-verification.php with verify_signature, verify_oauth_read,
verify_oauth_write, and verify_owner methods
- Update controllers to use the trait instead of static Server methods
- Maintain backwards compatibility by keeping static methods in Server class
---
includes/rest/class-actors-controller.php | 4 +-
.../rest/class-actors-inbox-controller.php | 2 +-
includes/rest/class-followers-controller.php | 4 +-
includes/rest/class-following-controller.php | 2 +-
includes/rest/class-inbox-controller.php | 9 +-
includes/rest/class-outbox-controller.php | 7 +-
includes/rest/class-proxy-controller.php | 4 +-
includes/rest/trait-verification.php | 128 ++++++++++++++++++
8 files changed, 147 insertions(+), 13 deletions(-)
create mode 100644 includes/rest/trait-verification.php
diff --git a/includes/rest/class-actors-controller.php b/includes/rest/class-actors-controller.php
index 070ff1cf51..b4e8d2accd 100644
--- a/includes/rest/class-actors-controller.php
+++ b/includes/rest/class-actors-controller.php
@@ -18,6 +18,8 @@
* @see https://www.w3.org/TR/activitypub/#followers
*/
class Actors_Controller extends \WP_REST_Controller {
+ use Verification;
+
/**
* The namespace of this controller's route.
*
@@ -51,7 +53,7 @@ public function register_routes() {
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
- 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
+ 'permission_callback' => array( $this, 'verify_signature' ),
),
'schema' => array( $this, 'get_public_item_schema' ),
)
diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php
index 0836dae688..bb277e1a99 100644
--- a/includes/rest/class-actors-inbox-controller.php
+++ b/includes/rest/class-actors-inbox-controller.php
@@ -65,7 +65,7 @@ public function register_routes() {
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
- 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
+ 'permission_callback' => array( $this, 'verify_signature' ),
'args' => array(
'id' => array(
'description' => 'The unique identifier for the activity.',
diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php
index b53e53e52b..7ae337716e 100644
--- a/includes/rest/class-followers-controller.php
+++ b/includes/rest/class-followers-controller.php
@@ -44,7 +44,7 @@ public function register_routes() {
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
- 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
+ 'permission_callback' => array( $this, 'verify_signature' ),
'args' => array(
'page' => array(
'description' => 'Current page of the collection.',
@@ -92,7 +92,7 @@ public function register_routes() {
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_partial_followers' ),
- 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
+ 'permission_callback' => array( $this, 'verify_signature' ),
'args' => array(
'authority' => array(
'description' => 'The host to filter followers by.',
diff --git a/includes/rest/class-following-controller.php b/includes/rest/class-following-controller.php
index 3d4a438701..1e6300066a 100644
--- a/includes/rest/class-following-controller.php
+++ b/includes/rest/class-following-controller.php
@@ -44,7 +44,7 @@ public function register_routes() {
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
- 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
+ 'permission_callback' => array( $this, 'verify_signature' ),
'args' => array(
'page' => array(
'description' => 'Current page of the collection.',
diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php
index 0ec0b373a9..cab4304f79 100644
--- a/includes/rest/class-inbox-controller.php
+++ b/includes/rest/class-inbox-controller.php
@@ -33,6 +33,7 @@
*/
class Inbox_Controller extends \WP_REST_Controller {
use Collection;
+ use Verification;
/**
* The namespace of this controller's route.
@@ -67,7 +68,7 @@ public function register_routes() {
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
- 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
+ 'permission_callback' => array( $this, 'verify_signature' ),
'args' => $this->get_create_item_args(),
),
'schema' => array( $this, 'get_item_schema' ),
@@ -109,7 +110,7 @@ public function register_routes() {
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
- 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
+ 'permission_callback' => array( $this, 'verify_signature' ),
'args' => $this->get_create_item_args(),
),
'schema' => array( $this, 'get_item_schema' ),
@@ -216,13 +217,13 @@ public function validate_user_id( $user_id ) {
*/
public function get_items_permissions_check( $request ) {
// Verify OAuth with read scope.
- $result = Server::verify_oauth_read( $request );
+ $result = $this->verify_oauth_read( $request );
if ( \is_wp_error( $result ) ) {
return $result;
}
// Verify the token belongs to the requested user.
- return Server::verify_owner( $request );
+ return $this->verify_owner( $request );
}
/**
diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php
index 9a5d50d108..8d2720a835 100644
--- a/includes/rest/class-outbox-controller.php
+++ b/includes/rest/class-outbox-controller.php
@@ -26,6 +26,7 @@
*/
class Outbox_Controller extends \WP_REST_Controller {
use Collection;
+ use Verification;
/**
* The namespace of this controller's route.
@@ -59,7 +60,7 @@ public function register_routes() {
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
- 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ),
+ 'permission_callback' => array( $this, 'verify_signature' ),
'args' => array(
'page' => array(
'description' => 'Current page of the collection.',
@@ -334,13 +335,13 @@ public function overload_total_items( $response, $request ) {
*/
public function create_item_permissions_check( $request ) {
// Verify OAuth with write scope.
- $result = Server::verify_oauth_write( $request );
+ $result = $this->verify_oauth_write( $request );
if ( \is_wp_error( $result ) ) {
return $result;
}
// Verify the token belongs to the requested user.
- return Server::verify_owner( $request );
+ return $this->verify_owner( $request );
}
/**
diff --git a/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php
index a5f84e1595..570bb2fb03 100644
--- a/includes/rest/class-proxy-controller.php
+++ b/includes/rest/class-proxy-controller.php
@@ -22,6 +22,8 @@
* Allows C2S clients to fetch remote ActivityPub objects through their home server.
*/
class Proxy_Controller extends \WP_REST_Controller {
+ use Verification;
+
/**
* The namespace of this controller's route.
*
@@ -70,7 +72,7 @@ public function register_routes() {
*/
public function get_item_permissions_check( $request ) {
// Verify OAuth with read scope.
- $result = Server::verify_oauth_read( $request );
+ $result = $this->verify_oauth_read( $request );
if ( \is_wp_error( $result ) ) {
return $result;
}
diff --git a/includes/rest/trait-verification.php b/includes/rest/trait-verification.php
new file mode 100644
index 0000000000..1e2f7386fa
--- /dev/null
+++ b/includes/rest/trait-verification.php
@@ -0,0 +1,128 @@
+get_method() ) {
+ return true;
+ }
+
+ /**
+ * Filter to defer signature verification.
+ *
+ * Skip signature verification for debugging purposes or to reduce load for
+ * certain Activity-Types, like "Delete".
+ *
+ * @param bool $defer Whether to defer signature verification.
+ * @param \WP_REST_Request $request The request used to generate the response.
+ * @return bool Whether to defer signature verification.
+ */
+ $defer = \apply_filters( 'activitypub_defer_signature_verification', false, $request );
+
+ if ( $defer ) {
+ return true;
+ }
+
+ // POST-Requests always have to be signed, GET-Requests only require a signature in secure mode.
+ if ( 'GET' !== $request->get_method() || use_authorized_fetch() ) {
+ $verified_request = Signature::verify_http_signature( $request );
+ if ( \is_wp_error( $verified_request ) ) {
+ return new \WP_Error(
+ 'activitypub_signature_verification',
+ $verified_request->get_error_message(),
+ array( 'status' => 401 )
+ );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Verify OAuth authentication with 'read' scope.
+ *
+ * Use this for endpoints requiring OAuth read access (C2S).
+ *
+ * @param \WP_REST_Request $request The request object.
+ * @return bool|\WP_Error True if authorized, WP_Error otherwise.
+ */
+ public function verify_oauth_read( $request ) {
+ return OAuth_Server::check_oauth_permission( $request, Scope::READ );
+ }
+
+ /**
+ * Verify OAuth authentication with 'write' scope.
+ *
+ * Use this for endpoints requiring OAuth write access (C2S).
+ *
+ * @param \WP_REST_Request $request The request object.
+ * @return bool|\WP_Error True if authorized, WP_Error otherwise.
+ */
+ public function verify_oauth_write( $request ) {
+ return OAuth_Server::check_oauth_permission( $request, Scope::WRITE );
+ }
+
+ /**
+ * Verify that the OAuth token belongs to the actor specified in the request.
+ *
+ * This checks that the user_id parameter matches the token's user.
+ * Should be called after verify_oauth_read or verify_oauth_write.
+ *
+ * @param \WP_REST_Request $request The request object.
+ * @return bool|\WP_Error True if the token user matches, WP_Error otherwise.
+ */
+ public function verify_owner( $request ) {
+ $user_id = $request->get_param( 'user_id' );
+
+ // Validate the user exists.
+ $user = Actors::get_by_id( $user_id );
+ if ( \is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ // Verify the token belongs to this user.
+ $token = OAuth_Server::get_current_token();
+
+ if ( ! $token || $token->get_user_id() !== absint( $user_id ) ) {
+ return new \WP_Error(
+ 'activitypub_forbidden',
+ \__( 'You can only access your own resources.', 'activitypub' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ return true;
+ }
+}
From 01de894d3be17782dfad294e492210115f9b7d5d Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Mon, 2 Feb 2026 09:33:44 +0100
Subject: [PATCH 018/105] Fix tests for deprecated handler methods and OAuth
mock
- Update handler tests to use incoming() instead of deprecated handle_* methods
- Add activitypub_oauth_check_permission filter for test mocking
- Fix proxy controller tests to use rest_api_init for route registration
- Update assertions to match actual return values (false vs null)
---
includes/oauth/class-server.php | 14 ++++
.../includes/handler/class-test-announce.php | 20 +++---
.../includes/handler/class-test-create.php | 64 +++++++++----------
.../includes/handler/class-test-follow.php | 18 +++---
.../includes/handler/class-test-like.php | 18 +++---
.../includes/handler/class-test-undo.php | 21 +++---
.../rest/class-test-proxy-controller.php | 2 +-
7 files changed, 85 insertions(+), 72 deletions(-)
diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php
index 23e5382537..d6900a0484 100644
--- a/includes/oauth/class-server.php
+++ b/includes/oauth/class-server.php
@@ -192,6 +192,20 @@ public static function generate_token( $length = 32 ) {
* @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;
+ }
+
// Must be authenticated via OAuth.
if ( ! self::is_oauth_request() ) {
return new \WP_Error(
diff --git a/tests/phpunit/tests/includes/handler/class-test-announce.php b/tests/phpunit/tests/includes/handler/class-test-announce.php
index 1c6ec81bf9..f43d5ebcad 100644
--- a/tests/phpunit/tests/includes/handler/class-test-announce.php
+++ b/tests/phpunit/tests/includes/handler/class-test-announce.php
@@ -102,7 +102,7 @@ public static function create_test_object() {
/**
* Test handle announce.
*
- * @covers ::handle_announce
+ * @covers ::incoming
*/
public function test_handle_announce() {
$external_actor = 'https://example.com/users/testuser';
@@ -116,7 +116,7 @@ public function test_handle_announce() {
'object' => $this->post_permalink,
);
- Announce::handle_announce( $object, $this->user_id );
+ Announce::incoming( $object, $this->user_id );
$args = array(
'type' => 'repost',
@@ -132,7 +132,7 @@ public function test_handle_announce() {
/**
* Test handle announces.
*
- * @covers ::handle_announce
+ * @covers ::incoming
*
* @dataProvider data_handle_announces
*
@@ -146,7 +146,7 @@ public function test_handle_announces( $announce, $recursion, $message ) {
\add_action( 'activitypub_inbox', array( $inbox_action, 'action' ) );
$activity = Activity::init_from_array( $announce );
- Announce::handle_announce( $announce, $this->user_id, $activity );
+ Announce::incoming( $announce, $this->user_id, $activity );
$this->assertEquals( $recursion, $inbox_action->get_call_count(), $message );
}
@@ -226,7 +226,7 @@ public static function data_handle_announces() {
/**
* Test that announces from the blog actor are ignored.
*
- * @covers ::handle_announce
+ * @covers ::incoming
*/
public function test_ignore_blog_actor_announce() {
$blog = new Blog();
@@ -246,7 +246,7 @@ public function test_ignore_blog_actor_announce() {
\add_action( 'activitypub_handled_announce', array( $handled_action, 'action' ) );
// Call with blog actor as sender - should be ignored.
- Announce::handle_announce( $object, $this->user_id );
+ Announce::incoming( $object, $this->user_id );
// Verify the announce was NOT handled.
$this->assertEquals( 0, $handled_action->get_call_count() );
@@ -268,7 +268,7 @@ public function test_ignore_blog_actor_announce() {
/**
* Test that announces from external actors are not ignored.
*
- * @covers ::handle_announce
+ * @covers ::incoming
*/
public function test_external_actor_announce_not_ignored() {
$external_actor = 'https://external.example.com/users/someone';
@@ -287,7 +287,7 @@ public function test_external_actor_announce_not_ignored() {
\add_action( 'activitypub_handled_announce', array( $handled_action, 'action' ) );
// Call with external actor - should be processed.
- Announce::handle_announce( $object, $this->user_id );
+ Announce::incoming( $object, $this->user_id );
// Verify the announce WAS handled.
$this->assertEquals( 1, $handled_action->get_call_count() );
@@ -310,7 +310,7 @@ public function test_external_actor_announce_not_ignored() {
/**
* Test that announces from same domain but different actor are not ignored.
*
- * @covers ::handle_announce
+ * @covers ::incoming
*/
public function test_same_domain_different_actor_not_ignored() {
// Get a regular user actor URL (not the blog actor).
@@ -330,7 +330,7 @@ public function test_same_domain_different_actor_not_ignored() {
\add_action( 'activitypub_handled_announce', array( $handled_action, 'action' ) );
// Call with same domain but user actor - should be processed.
- Announce::handle_announce( $object, $this->user_id );
+ Announce::incoming( $object, $this->user_id );
// Verify the announce WAS handled.
$this->assertEquals( 1, $handled_action->get_call_count() );
diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php
index 6c80d9c699..25c95fc1fe 100644
--- a/tests/phpunit/tests/includes/handler/class-test-create.php
+++ b/tests/phpunit/tests/includes/handler/class-test-create.php
@@ -126,23 +126,23 @@ public function create_test_object( $id = 'https://example.com/123' ) {
/**
* Test handle create.
*
- * @covers ::handle_create
+ * @covers ::incoming
*/
public function test_handle_create_non_public_rejected() {
$object = $this->create_test_object();
$object['cc'] = array();
- $converted = Create::handle_create( $object, $this->user_id );
- $this->assertNull( $converted );
+ $converted = Create::incoming( $object, $this->user_id );
+ $this->assertFalse( $converted );
}
/**
* Test handle create.
*
- * @covers ::handle_create
+ * @covers ::incoming
*/
public function test_handle_create_public_accepted() {
$object = $this->create_test_object();
- Create::handle_create( $object, $this->user_id );
+ Create::incoming( $object, $this->user_id );
$args = array(
'type' => 'comment',
@@ -160,13 +160,13 @@ public function test_handle_create_public_accepted() {
/**
* Test handle create.
*
- * @covers ::handle_create
+ * @covers ::incoming
*/
public function test_handle_create_public_accepted_without_type() {
$object = $this->create_test_object( 'https://example.com/123456' );
unset( $object['type'] );
- Create::handle_create( $object, $this->user_id );
+ Create::incoming( $object, $this->user_id );
$args = array(
'type' => 'comment',
@@ -183,12 +183,12 @@ public function test_handle_create_public_accepted_without_type() {
/**
* Test handle create check duplicate ID.
*
- * @covers ::handle_create
+ * @covers ::incoming
*/
public function test_handle_create_check_duplicate_id() {
$id = 'https://example.com/id/' . microtime( true );
$object = $this->create_test_object( $id );
- Create::handle_create( $object, $this->user_id );
+ Create::incoming( $object, $this->user_id );
$args = array(
'type' => 'comment',
@@ -203,7 +203,7 @@ public function test_handle_create_check_duplicate_id() {
$this->assertCount( 1, $result );
$object['object']['content'] = 'example2';
- Create::handle_create( $object, $this->user_id );
+ Create::incoming( $object, $this->user_id );
$args = array(
'type' => 'comment',
@@ -219,12 +219,12 @@ public function test_handle_create_check_duplicate_id() {
/**
* Test handle create check duplicate content.
*
- * @covers ::handle_create
+ * @covers ::incoming
*/
public function test_handle_create_check_duplicate_content() {
$id = 'https://example.com/id/' . microtime( true );
$object = $this->create_test_object( $id );
- Create::handle_create( $object, $this->user_id );
+ Create::incoming( $object, $this->user_id );
$args = array(
'type' => 'comment',
@@ -240,7 +240,7 @@ public function test_handle_create_check_duplicate_content() {
$id = 'https://example.com/id/' . microtime( true );
$object = $this->create_test_object( $id );
- Create::handle_create( $object, $this->user_id );
+ Create::incoming( $object, $this->user_id );
$args = array(
'type' => 'comment',
@@ -256,12 +256,12 @@ public function test_handle_create_check_duplicate_content() {
/**
* Test handle create multiple comments.
*
- * @covers ::handle_create
+ * @covers ::incoming
*/
public function test_handle_create_check_multiple_comments() {
$id = 'https://example.com/id/4711';
$object = $this->create_test_object( $id );
- Create::handle_create( $object, $this->user_id );
+ Create::incoming( $object, $this->user_id );
$args = array(
'type' => 'comment',
@@ -278,7 +278,7 @@ public function test_handle_create_check_multiple_comments() {
$id = 'https://example.com/id/23';
$object = $this->create_test_object( $id );
$object['object']['content'] = 'example2';
- Create::handle_create( $object, $this->user_id );
+ Create::incoming( $object, $this->user_id );
$args = array(
'type' => 'comment',
@@ -298,7 +298,7 @@ public function test_handle_create_check_multiple_comments() {
/**
* Test handling create activity for objects with content sanitization.
*
- * @covers ::handle_create
+ * @covers ::incoming
* @covers ::create_post
*/
public function test_handle_create_object_with_sanitization() {
@@ -340,7 +340,7 @@ public function test_handle_create_object_with_sanitization() {
\update_option( 'activitypub_create_posts', true );
- Create::handle_create( $activity, $this->user_id );
+ Create::incoming( $activity, $this->user_id );
// Verify the object was created with sanitized content.
$created_object = Posts::get_by_guid( 'https://example.com/objects/note_sanitize' );
@@ -360,7 +360,7 @@ public function test_handle_create_object_with_sanitization() {
/**
* Test handling private create activity.
*
- * @covers ::handle_create
+ * @covers ::incoming
*/
public function test_handle_create_private_activity() {
$private_activity = array(
@@ -384,7 +384,7 @@ public function test_handle_create_private_activity() {
)
);
- Create::handle_create( $private_activity, $this->user_id );
+ Create::incoming( $private_activity, $this->user_id );
// Count objects after.
$objects_after = get_posts(
@@ -402,7 +402,7 @@ public function test_handle_create_private_activity() {
/**
* Test create activity with malformed object data.
*
- * @covers ::handle_create
+ * @covers ::incoming
*/
public function test_handle_create_malformed_object() {
$malformed_activity = array(
@@ -425,7 +425,7 @@ public function test_handle_create_malformed_object() {
)
);
- Create::handle_create( $malformed_activity, $this->user_id );
+ Create::incoming( $malformed_activity, $this->user_id );
// Count objects after.
$objects_after = get_posts(
@@ -441,11 +441,11 @@ public function test_handle_create_malformed_object() {
}
/**
- * Test create_post returns false when activitypub_create_posts option is disabled.
+ * Test incoming returns false when activitypub_create_posts option is disabled.
*
- * @covers ::create_post
+ * @covers ::incoming
*/
- public function test_create_post_disabled_by_option() {
+ public function test_incoming_post_disabled_by_option() {
// Ensure option is not set.
\delete_option( 'activitypub_create_posts' );
@@ -481,7 +481,7 @@ public function test_create_post_disabled_by_option() {
),
);
- $result = Create::create_post( $activity, array( $this->user_id ) );
+ $result = Create::incoming( $activity, array( $this->user_id ) );
$this->assertFalse( $result );
@@ -493,11 +493,11 @@ public function test_create_post_disabled_by_option() {
}
/**
- * Test create_post works when activitypub_create_posts option is enabled.
+ * Test incoming works when activitypub_create_posts option is enabled.
*
- * @covers ::create_post
+ * @covers ::incoming
*/
- public function test_create_post_enabled_by_option() {
+ public function test_incoming_post_enabled_by_option() {
// Enable the option.
\update_option( 'activitypub_create_posts', '1' );
@@ -533,7 +533,7 @@ public function test_create_post_enabled_by_option() {
),
);
- $result = Create::create_post( $activity, array( $this->user_id ) );
+ $result = Create::incoming( $activity, array( $this->user_id ) );
$this->assertInstanceOf( 'WP_Post', $result );
@@ -566,9 +566,9 @@ public function test_reply_to_non_existent_post_returns_false() {
),
);
- $result = Create::handle_create( $object, $this->user_id );
+ $result = Create::incoming( $object, $this->user_id );
- $this->assertNull( $result );
+ $this->assertFalse( $result );
// Verify no comment was created.
$args = array(
diff --git a/tests/phpunit/tests/includes/handler/class-test-follow.php b/tests/phpunit/tests/includes/handler/class-test-follow.php
index ab2de4b296..aa5ca1139a 100644
--- a/tests/phpunit/tests/includes/handler/class-test-follow.php
+++ b/tests/phpunit/tests/includes/handler/class-test-follow.php
@@ -42,7 +42,7 @@ public static function wpSetUpBeforeClass( $factory ) {
* Test handle_follow method with different scenarios.
*
* @dataProvider handle_follow_provider
- * @covers ::handle_follow
+ * @covers ::incoming
*
* @param mixed $target_user_id The user ID being followed (int or 'test_user').
* @param string $actor_url The actor URL following.
@@ -81,7 +81,7 @@ public function test_handle_follow( $target_user_id, $actor_url, $expected_respo
$followers_before = Followers::get_many( $target_user_id );
$followers_count_before = count( $followers_before );
- Follow::handle_follow( $activity_object, $target_user_id );
+ Follow::incoming( $activity_object, $target_user_id );
// Check if follower was added.
$followers_after = Followers::get_many( $target_user_id );
@@ -240,7 +240,7 @@ public function test_queue_accept() {
/**
* Test that duplicate follow requests don't trigger notifications.
*
- * @covers ::handle_follow
+ * @covers ::incoming
*/
public function test_duplicate_follow_no_notification() {
$actor_url = 'https://example.com/duplicate-actor';
@@ -278,7 +278,7 @@ public function test_duplicate_follow_no_notification() {
\add_action( 'activitypub_handled_follow', $test_callback, 10, 4 );
// First follow request - should succeed.
- Follow::handle_follow( $activity_object, self::$user_id );
+ Follow::incoming( $activity_object, self::$user_id );
// Verify first follow was successful.
$this->assertCount( 1, $handled_follow_calls, 'First follow should trigger the action' );
@@ -291,7 +291,7 @@ public function test_duplicate_follow_no_notification() {
// Second follow request with a different activity ID (simulating a retry).
$activity_object['id'] = $actor_url . '/activity/follow-2';
- Follow::handle_follow( $activity_object, self::$user_id );
+ Follow::incoming( $activity_object, self::$user_id );
// Verify second follow was not successful (to prevent duplicate notification).
$this->assertCount( 2, $handled_follow_calls, 'Second follow should also trigger the action' );
@@ -356,7 +356,7 @@ public function test_queue_reject() {
/**
* Test that deprecated hook still fires for backward compatibility.
*
- * @covers ::handle_follow
+ * @covers ::incoming
*/
public function test_deprecated_hook_fires() {
// Expect the deprecation notice.
@@ -398,7 +398,7 @@ public function test_deprecated_hook_fires() {
'object' => Actors::get_by_id( self::$user_id )->get_id(),
);
- Follow::handle_follow( $activity_object, self::$user_id );
+ Follow::incoming( $activity_object, self::$user_id );
// Verify deprecated hook fired.
$this->assertTrue( $hook_fired, 'Deprecated hook should fire' );
@@ -415,7 +415,7 @@ public function test_deprecated_hook_fires() {
/**
* Test new hook fires correctly.
*
- * @covers ::handle_follow
+ * @covers ::incoming
*/
public function test_new_hook_fires() {
$hook_fired = false;
@@ -455,7 +455,7 @@ public function test_new_hook_fires() {
'object' => Actors::get_by_id( self::$user_id )->get_id(),
);
- Follow::handle_follow( $activity_object, self::$user_id );
+ Follow::incoming( $activity_object, self::$user_id );
// Verify new hook fired.
$this->assertTrue( $hook_fired, 'New hook should fire' );
diff --git a/tests/phpunit/tests/includes/handler/class-test-like.php b/tests/phpunit/tests/includes/handler/class-test-like.php
index 7ae710e426..595ddd575c 100644
--- a/tests/phpunit/tests/includes/handler/class-test-like.php
+++ b/tests/phpunit/tests/includes/handler/class-test-like.php
@@ -110,7 +110,7 @@ public function create_test_object() {
* Test handle_like with different scenarios.
*
* @dataProvider handle_like_provider
- * @covers ::handle_like
+ * @covers ::incoming
*
* @param array $activity_data The like activity data.
* @param bool $should_create_comment Whether a comment should be created.
@@ -130,7 +130,7 @@ public function test_handle_like( $activity_data, $should_create_comment, $descr
$count_before = count( $comments_before );
// Process the like.
- Like::handle_like( $activity, $this->user_id );
+ Like::incoming( $activity, $this->user_id );
// Check comment count after.
$comments_after = \get_comments(
@@ -191,7 +191,7 @@ public function handle_like_provider() {
* This test verifies that Like activities from Pixelfed and other platforms
* that include trailing slashes in object URLs are processed correctly.
*
- * @covers ::handle_like
+ * @covers ::incoming
* @covers \Activitypub\Collection\Interactions::add_reaction
*/
public function test_handle_like_with_trailing_slash() {
@@ -214,7 +214,7 @@ public function test_handle_like_with_trailing_slash() {
$count_before = count( $comments_before );
// Process the like.
- Like::handle_like( $activity, $this->user_id );
+ Like::incoming( $activity, $this->user_id );
// Check that comment was created despite trailing slash.
$comments_after = \get_comments(
@@ -232,7 +232,7 @@ public function test_handle_like_with_trailing_slash() {
/**
* Test duplicate like handling.
*
- * @covers ::handle_like
+ * @covers ::incoming
*/
public function test_handle_like_duplicate() {
$activity = array_merge(
@@ -241,7 +241,7 @@ public function test_handle_like_duplicate() {
);
// Process the like first time.
- Like::handle_like( $activity, $this->user_id );
+ Like::incoming( $activity, $this->user_id );
$comments_after_first = \get_comments(
array(
@@ -252,7 +252,7 @@ public function test_handle_like_duplicate() {
$count_after_first = count( $comments_after_first );
// Process the same like again.
- Like::handle_like( $activity, $this->user_id );
+ Like::incoming( $activity, $this->user_id );
$comments_after_second = \get_comments(
array(
@@ -268,7 +268,7 @@ public function test_handle_like_duplicate() {
/**
* Test handle_like action hook fires.
*
- * @covers ::handle_like
+ * @covers ::incoming
*/
public function test_handle_like_action_hook() {
$hook_fired = false;
@@ -287,7 +287,7 @@ public function test_handle_like_action_hook() {
\add_action( 'activitypub_handled_like', $handled_like_callback, 10, 4 );
$activity = $this->create_test_object();
- Like::handle_like( $activity, $this->user_id );
+ Like::incoming( $activity, $this->user_id );
// Verify hook was fired.
$this->assertTrue( $hook_fired, 'Action hook should be fired' );
diff --git a/tests/phpunit/tests/includes/handler/class-test-undo.php b/tests/phpunit/tests/includes/handler/class-test-undo.php
index 2b419f8763..d8d8ad71c4 100644
--- a/tests/phpunit/tests/includes/handler/class-test-undo.php
+++ b/tests/phpunit/tests/includes/handler/class-test-undo.php
@@ -55,7 +55,7 @@ public function set_up() {
* Test handle_undo with follow activities.
*
* @dataProvider follow_undo_provider
- * @covers ::handle_undo
+ * @covers ::incoming
*
* @param string $actor_url The actor URL to test with.
* @param string $description Description of the test case.
@@ -96,7 +96,7 @@ public function test_handle_undo_follow( $actor_url, $description ) {
Inbox_Collection::add( $activity_object, self::$user_id );
// Call the Follow handler directly to add the follower.
- \Activitypub\Handler\Follow::handle_follow( $follow_activity, self::$user_id );
+ \Activitypub\Handler\Follow::incoming( $follow_activity, self::$user_id );
// Verify follower was added.
$followers = Followers::get_many( self::$user_id );
@@ -117,7 +117,7 @@ public function test_handle_undo_follow( $actor_url, $description ) {
);
// Call the Undo handler directly.
- Undo::handle_undo( $undo_activity, self::$user_id );
+ Undo::incoming( $undo_activity, self::$user_id );
// Verify follower was removed.
$followers_after = Followers::get_many( self::$user_id );
@@ -153,7 +153,7 @@ public function follow_undo_provider() {
* Test handle_undo with comment-related activities (Like, Create, Announce).
*
* @dataProvider comment_activities_undo_provider
- * @covers ::handle_undo
+ * @covers ::incoming
*
* @param string $actor_url The actor URL to test with.
* @param string $activity_type The type of activity being undone.
@@ -199,9 +199,8 @@ public function test_handle_undo_comment_activities( $actor_url, $activity_type,
Inbox_Collection::add( $activity_object, self::$user_id );
// Call the appropriate handler directly to create the comment.
- $handler_class = '\\Activitypub\\Handler\\' . $activity_type;
- $handler_method = 'handle_' . strtolower( $activity_type );
- $handler_class::$handler_method( $create_activity, self::$user_id );
+ $handler_class = '\\Activitypub\\Handler\\' . $activity_type;
+ $handler_class::incoming( $create_activity, self::$user_id );
// Find the comment that was created.
$found_comment = Comment::object_id_to_comment( $activity_id );
@@ -221,7 +220,7 @@ public function test_handle_undo_comment_activities( $actor_url, $activity_type,
);
// Call the Undo handler directly.
- Undo::handle_undo( $undo_activity, self::$user_id );
+ Undo::incoming( $undo_activity, self::$user_id );
// Verify comment was deleted.
$comment_after = \get_comment( $comment_id );
@@ -249,7 +248,7 @@ public function comment_activities_undo_provider() {
/**
* Test handle_undo action hook is fired.
*
- * @covers ::handle_undo
+ * @covers ::incoming
*/
public function test_handle_undo_action_hook() {
$action_fired = false;
@@ -298,7 +297,7 @@ public function test_handle_undo_action_hook() {
$activity_object = Activity::init_from_array( $follow_activity );
Inbox_Collection::add( $activity_object, self::$user_id );
- \Activitypub\Handler\Follow::handle_follow( $follow_activity, self::$user_id );
+ \Activitypub\Handler\Follow::incoming( $follow_activity, self::$user_id );
// Create Undo activity.
$activity = array(
@@ -315,7 +314,7 @@ public function test_handle_undo_action_hook() {
);
// Call the Undo handler directly.
- Undo::handle_undo( $activity, self::$user_id );
+ Undo::incoming( $activity, self::$user_id );
$this->assertTrue( $action_fired );
$this->assertEquals( $activity, $activity_data );
diff --git a/tests/phpunit/tests/rest/class-test-proxy-controller.php b/tests/phpunit/tests/rest/class-test-proxy-controller.php
index 86a046f924..df386c05fb 100644
--- a/tests/phpunit/tests/rest/class-test-proxy-controller.php
+++ b/tests/phpunit/tests/rest/class-test-proxy-controller.php
@@ -50,7 +50,7 @@ public function set_up() {
$wp_rest_server = new \WP_REST_Server();
$this->server = $wp_rest_server;
- ( new Proxy_Controller() )->register_routes();
+ \do_action( 'rest_api_init' );
}
/**
From 3784ed84cc64749149c294f94ff8642f20edec7e Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Mon, 2 Feb 2026 09:44:03 +0100
Subject: [PATCH 019/105] Remove invalid @covers annotation for non-existent
create_post method
---
tests/phpunit/tests/includes/handler/class-test-create.php | 1 -
1 file changed, 1 deletion(-)
diff --git a/tests/phpunit/tests/includes/handler/class-test-create.php b/tests/phpunit/tests/includes/handler/class-test-create.php
index 25c95fc1fe..ba16ca92d3 100644
--- a/tests/phpunit/tests/includes/handler/class-test-create.php
+++ b/tests/phpunit/tests/includes/handler/class-test-create.php
@@ -299,7 +299,6 @@ public function test_handle_create_check_multiple_comments() {
* Test handling create activity for objects with content sanitization.
*
* @covers ::incoming
- * @covers ::create_post
*/
public function test_handle_create_object_with_sanitization() {
// Mock HTTP request for Remote_Actors::fetch_by_uri.
From c9e083484e1a920933c7232f41aa2fd6ce8a2459 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Tue, 3 Feb 2026 12:21:31 +0100
Subject: [PATCH 020/105] Move C2S user inbox logic from Inbox_Controller to
Actors_Inbox_Controller
Consolidates user inbox handling in the appropriate controller:
- Actors_Inbox_Controller now handles user inbox GET (C2S) and POST (S2S)
- Inbox_Controller now only handles shared inbox POST (S2S)
---
.../rest/class-actors-inbox-controller.php | 110 ++++++++--
includes/rest/class-inbox-controller.php | 189 ------------------
2 files changed, 94 insertions(+), 205 deletions(-)
diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php
index bb277e1a99..70aaf74ef7 100644
--- a/includes/rest/class-actors-inbox-controller.php
+++ b/includes/rest/class-actors-inbox-controller.php
@@ -8,6 +8,8 @@
namespace Activitypub\Rest;
use Activitypub\Activity\Activity;
+use Activitypub\Activity\Base_Object;
+use Activitypub\Collection\Actors;
use Activitypub\Collection\Inbox;
use Activitypub\Moderation;
@@ -45,7 +47,7 @@ public function register_routes() {
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
- 'permission_callback' => '__return_true',
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
'args' => array(
'page' => array(
'description' => 'Current page of the collection.',
@@ -58,6 +60,7 @@ public function register_routes() {
'type' => 'integer',
'default' => 20,
'minimum' => 1,
+ 'maximum' => 100,
),
),
'schema' => array( $this, 'get_collection_schema' ),
@@ -109,33 +112,85 @@ public function register_routes() {
}
/**
- * Renders the user-inbox.
+ * Permission check for reading inbox items (C2S).
*
- * @param \WP_REST_Request $request The request object.
- * @return \WP_REST_Response|\WP_Error Response object or WP_Error.
+ * @param \WP_REST_Request $request Full details about the request.
+ * @return bool|\WP_Error True if authorized, WP_Error otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ // Verify OAuth with read scope.
+ $result = $this->verify_oauth_read( $request );
+ if ( \is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ // Verify the token belongs to the requested user.
+ return $this->verify_owner( $request );
+ }
+
+ /**
+ * Retrieves a collection of inbox items.
+ *
+ * @param \WP_REST_Request $request Full details about the request.
+ * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items( $request ) {
+ $page = $request->get_param( 'page' ) ?? 1;
$user_id = $request->get_param( 'user_id' );
+ $user = Actors::get_by_id( $user_id );
/**
- * Fires before the ActivityPub inbox is created and sent to the client.
+ * Action triggered prior to the ActivityPub inbox being created and sent to the client.
+ *
+ * @param \WP_REST_Request $request The request object.
+ */
+ \do_action( 'activitypub_rest_inbox_pre', $request );
+
+ $args = array(
+ 'posts_per_page' => $request->get_param( 'per_page' ),
+ 'paged' => $page,
+ 'post_type' => Inbox::POST_TYPE,
+ 'post_status' => 'publish',
+ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ 'meta_query' => array(
+ array(
+ 'key' => '_activitypub_user_id',
+ 'value' => $user_id,
+ ),
+ ),
+ );
+
+ /**
+ * Filters WP_Query arguments when querying Inbox items via the REST API.
+ *
+ * Enables adding extra arguments or setting defaults for an inbox collection request.
+ *
+ * @param array $args Array of arguments for WP_Query.
+ * @param \WP_REST_Request $request The REST API request.
*/
- \do_action( 'activitypub_rest_inbox_pre' );
+ $args = \apply_filters( 'activitypub_rest_inbox_query', $args, $request );
+
+ $inbox_query = new \WP_Query();
+ $query_result = $inbox_query->query( $args );
$response = array(
- 'id' => get_rest_url_by_path( \sprintf( 'actors/%d/inbox', $user_id ) ),
+ '@context' => Base_Object::JSON_LD_CONTEXT,
+ 'id' => get_rest_url_by_path( sprintf( 'actors/%d/inbox', $user_id ) ),
'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
+ 'actor' => $user->get_id(),
'type' => 'OrderedCollection',
- 'totalItems' => 0,
+ 'totalItems' => (int) $inbox_query->found_posts,
'orderedItems' => array(),
);
- /**
- * Filters the ActivityPub inbox data before it is sent to the client.
- *
- * @param array $response The ActivityPub inbox array.
- */
- $response = \apply_filters( 'activitypub_rest_inbox_array', $response );
+ \update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) );
+ foreach ( $query_result as $inbox_item ) {
+ if ( ! $inbox_item instanceof \WP_Post ) {
+ continue;
+ }
+
+ $response['orderedItems'][] = $this->prepare_item_for_response( $inbox_item, $request );
+ }
$response = $this->prepare_collection_response( $response, $request );
if ( \is_wp_error( $response ) ) {
@@ -143,9 +198,19 @@ public function get_items( $request ) {
}
/**
- * Fires after the ActivityPub inbox has been created and sent to the client.
+ * Filter the ActivityPub inbox array.
+ *
+ * @param array $response The ActivityPub inbox array.
+ * @param \WP_REST_Request $request The request object.
+ */
+ $response = \apply_filters( 'activitypub_rest_inbox_array', $response, $request );
+
+ /**
+ * Action triggered after the ActivityPub inbox has been created and sent to the client.
+ *
+ * @param \WP_REST_Request $request The request object.
*/
- \do_action( 'activitypub_inbox_post' );
+ \do_action( 'activitypub_rest_inbox_post', $request );
$response = \rest_ensure_response( $response );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
@@ -153,6 +218,19 @@ public function get_items( $request ) {
return $response;
}
+ /**
+ * Prepares the item for the REST response.
+ *
+ * @param mixed $item WordPress representation of the item.
+ * @param \WP_REST_Request $request Request object.
+ * @return array Response object on success.
+ */
+ public function prepare_item_for_response( $item, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
+ $activity = \json_decode( $item->post_content, true );
+
+ return $activity;
+ }
+
/**
* Handles user-inbox requests.
*
diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php
index cab4304f79..d7faa14467 100644
--- a/includes/rest/class-inbox-controller.php
+++ b/includes/rest/class-inbox-controller.php
@@ -8,7 +8,6 @@
namespace Activitypub\Rest;
use Activitypub\Activity\Activity;
-use Activitypub\Activity\Base_Object;
use Activitypub\Collection\Actors;
use Activitypub\Collection\Following;
use Activitypub\Collection\Inbox;
@@ -17,8 +16,6 @@
use function Activitypub\camel_to_snake_case;
use function Activitypub\extract_recipients_from_activity;
-use function Activitypub\get_masked_wp_version;
-use function Activitypub\get_rest_url_by_path;
use function Activitypub\is_activity_public;
use function Activitypub\is_collection;
use function Activitypub\is_same_domain;
@@ -32,7 +29,6 @@
* @see https://www.w3.org/TR/activitypub/#inbox
*/
class Inbox_Controller extends \WP_REST_Controller {
- use Collection;
use Verification;
/**
@@ -49,18 +45,10 @@ class Inbox_Controller extends \WP_REST_Controller {
*/
protected $rest_base = 'inbox';
- /**
- * The base for user-specific inbox routes.
- *
- * @var string
- */
- protected $user_rest_base = '(?:users|actors)/(?P[\-]?\d+)/inbox';
-
/**
* Register routes.
*/
public function register_routes() {
- // Shared inbox (POST only).
\register_rest_route(
$this->namespace,
'/' . $this->rest_base,
@@ -74,48 +62,6 @@ public function register_routes() {
'schema' => array( $this, 'get_item_schema' ),
)
);
-
- // User-specific inbox (GET for C2S, POST for S2S).
- \register_rest_route(
- $this->namespace,
- '/' . $this->user_rest_base,
- array(
- 'args' => array(
- 'user_id' => array(
- 'description' => 'The ID of the user or actor.',
- 'type' => 'integer',
- 'validate_callback' => array( $this, 'validate_user_id' ),
- ),
- ),
- array(
- 'methods' => \WP_REST_Server::READABLE,
- 'callback' => array( $this, 'get_items' ),
- 'permission_callback' => array( $this, 'get_items_permissions_check' ),
- 'args' => array(
- 'page' => array(
- 'description' => 'Current page of the collection.',
- 'type' => 'integer',
- 'minimum' => 1,
- // No default so we can differentiate between Collection and CollectionPage requests.
- ),
- 'per_page' => array(
- 'description' => 'Maximum number of items to be returned in result set.',
- 'type' => 'integer',
- 'default' => 20,
- 'minimum' => 1,
- 'maximum' => 100,
- ),
- ),
- ),
- array(
- 'methods' => \WP_REST_Server::CREATABLE,
- 'callback' => array( $this, 'create_item' ),
- 'permission_callback' => array( $this, 'verify_signature' ),
- 'args' => $this->get_create_item_args(),
- ),
- 'schema' => array( $this, 'get_item_schema' ),
- )
- );
}
/**
@@ -194,141 +140,6 @@ private function get_create_item_args() {
);
}
- /**
- * Validates the user_id parameter.
- *
- * @param mixed $user_id The user_id parameter.
- * @return bool|\WP_Error True if the user_id is valid, WP_Error otherwise.
- */
- public function validate_user_id( $user_id ) {
- $user = Actors::get_by_id( $user_id );
- if ( \is_wp_error( $user ) ) {
- return $user;
- }
-
- return true;
- }
-
- /**
- * Permission check for reading inbox items (C2S).
- *
- * @param \WP_REST_Request $request Full details about the request.
- * @return bool|\WP_Error True if authorized, WP_Error otherwise.
- */
- public function get_items_permissions_check( $request ) {
- // Verify OAuth with read scope.
- $result = $this->verify_oauth_read( $request );
- if ( \is_wp_error( $result ) ) {
- return $result;
- }
-
- // Verify the token belongs to the requested user.
- return $this->verify_owner( $request );
- }
-
- /**
- * Retrieves a collection of inbox items.
- *
- * @param \WP_REST_Request $request Full details about the request.
- * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure.
- */
- public function get_items( $request ) {
- $page = $request->get_param( 'page' ) ?? 1;
- $user_id = $request->get_param( 'user_id' );
- $user = Actors::get_by_id( $user_id );
-
- /**
- * Action triggered prior to the ActivityPub inbox being created and sent to the client.
- *
- * @param \WP_REST_Request $request The request object.
- */
- \do_action( 'activitypub_rest_inbox_pre', $request );
-
- $args = array(
- 'posts_per_page' => $request->get_param( 'per_page' ),
- 'paged' => $page,
- 'post_type' => Inbox::POST_TYPE,
- 'post_status' => 'publish',
- // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
- 'meta_query' => array(
- array(
- 'key' => '_activitypub_user_id',
- 'value' => $user_id,
- ),
- ),
- );
-
- /**
- * Filters WP_Query arguments when querying Inbox items via the REST API.
- *
- * Enables adding extra arguments or setting defaults for an inbox collection request.
- *
- * @param array $args Array of arguments for WP_Query.
- * @param \WP_REST_Request $request The REST API request.
- */
- $args = \apply_filters( 'activitypub_rest_inbox_query', $args, $request );
-
- $inbox_query = new \WP_Query();
- $query_result = $inbox_query->query( $args );
-
- $response = array(
- '@context' => Base_Object::JSON_LD_CONTEXT,
- 'id' => get_rest_url_by_path( sprintf( 'actors/%d/inbox', $user_id ) ),
- 'generator' => 'https://wordpress.org/?v=' . get_masked_wp_version(),
- 'actor' => $user->get_id(),
- 'type' => 'OrderedCollection',
- 'totalItems' => (int) $inbox_query->found_posts,
- 'orderedItems' => array(),
- );
-
- \update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) );
- foreach ( $query_result as $inbox_item ) {
- if ( ! $inbox_item instanceof \WP_Post ) {
- continue;
- }
-
- $response['orderedItems'][] = $this->prepare_item_for_response( $inbox_item, $request );
- }
-
- $response = $this->prepare_collection_response( $response, $request );
- if ( \is_wp_error( $response ) ) {
- return $response;
- }
-
- /**
- * Filter the ActivityPub inbox array.
- *
- * @param array $response The ActivityPub inbox array.
- * @param \WP_REST_Request $request The request object.
- */
- $response = \apply_filters( 'activitypub_rest_inbox_array', $response, $request );
-
- /**
- * Action triggered after the ActivityPub inbox has been created and sent to the client.
- *
- * @param \WP_REST_Request $request The request object.
- */
- \do_action( 'activitypub_rest_inbox_post', $request );
-
- $response = \rest_ensure_response( $response );
- $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
-
- return $response;
- }
-
- /**
- * Prepares the item for the REST response.
- *
- * @param mixed $item WordPress representation of the item.
- * @param \WP_REST_Request $request Request object.
- * @return array Response object on success.
- */
- public function prepare_item_for_response( $item, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
- $activity = \json_decode( $item->post_content, true );
-
- return $activity;
- }
-
/**
* The shared inbox.
*
From c627795ef87969c61dd9a06d1661655b983493ae Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Tue, 3 Feb 2026 12:25:31 +0100
Subject: [PATCH 021/105] Remove duplicate verify_* methods from Server class
These methods are now provided by the Verification trait which controllers
use directly. Removes 278 lines of duplicated code.
---
includes/rest/class-server.php | 116 -------------
.../tests/includes/rest/class-test-server.php | 162 ------------------
2 files changed, 278 deletions(-)
diff --git a/includes/rest/class-server.php b/includes/rest/class-server.php
index 70a4f8a80f..1c5b90c47b 100644
--- a/includes/rest/class-server.php
+++ b/includes/rest/class-server.php
@@ -7,13 +7,6 @@
namespace Activitypub\Rest;
-use Activitypub\Collection\Actors;
-use Activitypub\OAuth\Scope;
-use Activitypub\OAuth\Server as OAuth_Server;
-use Activitypub\Signature;
-
-use function Activitypub\use_authorized_fetch;
-
/**
* ActivityPub Server REST-Class.
*
@@ -32,115 +25,6 @@ public static function init() {
\add_filter( 'rest_post_dispatch', array( self::class, 'filter_output' ), 10, 3 );
}
- /**
- * Callback function to authorize an api request.
- *
- * The function is meant to be used as part of permission callbacks for rest api endpoints.
- *
- * It verifies the signature of POST, PUT, PATCH, and DELETE requests, as well as GET requests in secure mode.
- * You can use the filter 'activitypub_defer_signature_verification' to defer the signature verification.
- * HEAD requests are always bypassed.
- *
- * @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
- * @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
- *
- * @param \WP_REST_Request $request The request object.
- *
- * @return bool|\WP_Error True if the request is authorized, WP_Error if not.
- */
- public static function verify_signature( $request ) {
- if ( 'HEAD' === $request->get_method() ) {
- return true;
- }
-
- /**
- * Filter to defer signature verification.
- *
- * Skip signature verification for debugging purposes or to reduce load for
- * certain Activity-Types, like "Delete".
- *
- * @param bool $defer Whether to defer signature verification.
- * @param \WP_REST_Request $request The request used to generate the response.
- *
- * @return bool Whether to defer signature verification.
- */
- $defer = \apply_filters( 'activitypub_defer_signature_verification', false, $request );
-
- if ( $defer ) {
- return true;
- }
-
- // POST-Requests always have to be signed, GET-Requests only require a signature in secure mode.
- if ( 'GET' !== $request->get_method() || use_authorized_fetch() ) {
- $verified_request = Signature::verify_http_signature( $request );
- if ( \is_wp_error( $verified_request ) ) {
- return new \WP_Error(
- 'activitypub_signature_verification',
- $verified_request->get_error_message(),
- array( 'status' => 401 )
- );
- }
- }
-
- return true;
- }
-
- /**
- * Verify OAuth authentication with 'read' scope.
- *
- * Use this as a permission_callback for endpoints requiring OAuth read access.
- *
- * @param \WP_REST_Request $request The request object.
- * @return bool|\WP_Error True if authorized, WP_Error otherwise.
- */
- public static function verify_oauth_read( $request ) {
- return OAuth_Server::check_oauth_permission( $request, Scope::READ );
- }
-
- /**
- * Verify OAuth authentication with 'write' scope.
- *
- * Use this as a permission_callback for endpoints requiring OAuth write access.
- *
- * @param \WP_REST_Request $request The request object.
- * @return bool|\WP_Error True if authorized, WP_Error otherwise.
- */
- public static function verify_oauth_write( $request ) {
- return OAuth_Server::check_oauth_permission( $request, Scope::WRITE );
- }
-
- /**
- * Verify that the OAuth token belongs to the actor specified in the request.
- *
- * This checks that the user_id parameter matches the token's user.
- * Should be called after verify_oauth_read or verify_oauth_write.
- *
- * @param \WP_REST_Request $request The request object.
- * @return bool|\WP_Error True if the token user matches, WP_Error otherwise.
- */
- public static function verify_owner( $request ) {
- $user_id = $request->get_param( 'user_id' );
-
- // Validate the user exists.
- $user = Actors::get_by_id( $user_id );
- if ( \is_wp_error( $user ) ) {
- return $user;
- }
-
- // Verify the token belongs to this user.
- $token = OAuth_Server::get_current_token();
-
- if ( ! $token || $token->get_user_id() !== absint( $user_id ) ) {
- return new \WP_Error(
- 'activitypub_forbidden',
- \__( 'You can only access your own resources.', 'activitypub' ),
- array( 'status' => 403 )
- );
- }
-
- return true;
- }
-
/**
* Callback function to validate incoming ActivityPub requests
*
diff --git a/tests/phpunit/tests/includes/rest/class-test-server.php b/tests/phpunit/tests/includes/rest/class-test-server.php
index 4e7c5585b5..bd3b455502 100644
--- a/tests/phpunit/tests/includes/rest/class-test-server.php
+++ b/tests/phpunit/tests/includes/rest/class-test-server.php
@@ -36,168 +36,6 @@ public function test_init() {
$this->assertEquals( 10, \has_filter( 'rest_post_dispatch', array( Server::class, 'filter_output' ) ) );
}
- /**
- * Test verify_signature method with HEAD request.
- *
- * @covers ::verify_signature
- */
- public function test_verify_signature_head_request() {
- $request = new \WP_REST_Request( 'HEAD', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' );
- $this->assertTrue( Server::verify_signature( $request ) );
- }
-
- /**
- * Test verify_signature method with deferred verification.
- *
- * @covers ::verify_signature
- */
- public function test_verify_signature_deferred() {
- \add_filter( 'activitypub_defer_signature_verification', '__return_true' );
-
- $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' );
- $this->assertTrue( Server::verify_signature( $request ) );
-
- \remove_filter( 'activitypub_defer_signature_verification', '__return_true' );
- }
-
- /**
- * Data provider for HTTP methods that require signature verification.
- *
- * @return array
- */
- public function signature_required_methods_provider() {
- return array(
- 'POST request' => array( 'POST', true ),
- 'PUT request' => array( 'PUT', false ),
- 'PATCH request' => array( 'PATCH', false ),
- 'DELETE request' => array( 'DELETE', false ),
- );
- }
-
- /**
- * Test verify_signature method with requests requiring signature.
- *
- * @dataProvider signature_required_methods_provider
- * @covers ::verify_signature
- *
- * @param string $method HTTP method.
- * @param bool $expect_status Whether to expect status in error data.
- */
- public function test_verify_signature_methods_requiring_signature( $method, $expect_status ) {
- $request = new \WP_REST_Request( $method, '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' );
- $result = Server::verify_signature( $request );
- $this->assertInstanceOf( '\WP_Error', $result );
- $this->assertEquals( 'activitypub_signature_verification', $result->get_error_code() );
-
- if ( $expect_status ) {
- $this->assertEquals( 401, $result->get_error_data()['status'] );
- }
- }
-
- /**
- * Test verify_signature method with GET request and authorized fetch enabled.
- *
- * @covers ::verify_signature
- */
- public function test_verify_signature_get_request_authorized_fetch() {
- \add_filter( 'activitypub_use_authorized_fetch', '__return_true' );
-
- $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' );
- $result = Server::verify_signature( $request );
- $this->assertInstanceOf( '\WP_Error', $result );
- $this->assertEquals( 'activitypub_signature_verification', $result->get_error_code() );
-
- \remove_filter( 'activitypub_use_authorized_fetch', '__return_true' );
- }
-
- /**
- * Test verify_signature method with GET request and authorized fetch disabled.
- *
- * @covers ::verify_signature
- */
- public function test_verify_signature_get_request_no_authorized_fetch() {
- $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' );
- $this->assertTrue( Server::verify_signature( $request ) );
- }
-
- /**
- * Test verify_signature method with custom filter callback.
- *
- * @covers ::verify_signature
- */
- public function test_verify_signature_with_custom_filter() {
- $filter_called = false;
- $test_filter = function ( $defer, $request ) use ( &$filter_called ) {
- $filter_called = true;
- $this->assertFalse( $defer );
- $this->assertInstanceOf( '\WP_REST_Request', $request );
- return false;
- };
-
- \add_filter( 'activitypub_defer_signature_verification', $test_filter, 10, 2 );
-
- $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' );
- $result = Server::verify_signature( $request );
-
- $this->assertTrue( $filter_called );
- $this->assertInstanceOf( '\WP_Error', $result );
-
- \remove_filter( 'activitypub_defer_signature_verification', $test_filter );
- }
-
- /**
- * Test verify_signature method with filter that returns different values.
- *
- * @covers ::verify_signature
- */
- public function test_verify_signature_filter_context() {
- $defer_filter = function ( $defer, $request ) {
- // Test that filter receives correct parameters.
- if ( $request->get_method() === 'PUT' ) {
- return true; // Defer for PUT.
- }
- return $defer; // Don't defer for others.
- };
-
- \add_filter( 'activitypub_defer_signature_verification', $defer_filter, 10, 2 );
-
- // Test PUT request (should be deferred).
- $put_request = new \WP_REST_Request( 'PUT', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' );
- $this->assertTrue( Server::verify_signature( $put_request ) );
-
- // Test POST request (should not be deferred).
- $post_request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' );
- $result = Server::verify_signature( $post_request );
- $this->assertInstanceOf( '\WP_Error', $result );
-
- \remove_filter( 'activitypub_defer_signature_verification', $defer_filter );
- }
-
- /**
- * Test verify_signature method.
- *
- * @covers ::verify_signature
- */
- public function test_verify_signature() {
- // HEAD requests are always bypassed.
- $request = new \WP_REST_Request( 'HEAD', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' );
- $this->assertTrue( Server::verify_signature( $request ) );
-
- // POST requests require a signature.
- $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' );
- $this->assertErrorResponse( 'activitypub_signature_verification', Server::verify_signature( $request ) );
-
- // GET requests with secure mode enabled require a signature.
- \add_filter( 'activitypub_use_authorized_fetch', '__return_true' );
-
- $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/inbox' );
- $this->assertErrorResponse( 'activitypub_signature_verification', Server::verify_signature( $request ) );
-
- // GET requests with secure mode disabled are bypassed.
- \remove_filter( 'activitypub_use_authorized_fetch', '__return_true' );
- $this->assertTrue( Server::verify_signature( $request ) );
- }
-
/**
* Data provider for validate_requests scenarios that return response unchanged.
*
From 1508dc35a64b10bb3104fe0934c857456fc4bad6 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Tue, 3 Feb 2026 12:30:48 +0100
Subject: [PATCH 022/105] Remove inbox E2E tests that require OAuth
The inbox GET endpoint now requires OAuth authentication (C2S).
E2E tests cannot easily test OAuth-protected endpoints without a full
OAuth flow. The functionality is covered by PHPUnit tests.
---
.../includes/rest/inbox-controller.test.js | 154 ------------------
1 file changed, 154 deletions(-)
delete mode 100644 tests/e2e/specs/includes/rest/inbox-controller.test.js
diff --git a/tests/e2e/specs/includes/rest/inbox-controller.test.js b/tests/e2e/specs/includes/rest/inbox-controller.test.js
deleted file mode 100644
index a17543699c..0000000000
--- a/tests/e2e/specs/includes/rest/inbox-controller.test.js
+++ /dev/null
@@ -1,154 +0,0 @@
-/**
- * WordPress dependencies
- */
-import { test, expect } from '@wordpress/e2e-test-utils-playwright';
-
-test.describe( 'ActivityPub Inbox REST API', () => {
- let testUserId;
- let inboxEndpoint;
-
- test.beforeAll( async () => {
- // Use the default test user
- testUserId = 1;
- inboxEndpoint = `/activitypub/1.0/actors/${ testUserId }/inbox`;
- } );
-
- test( 'should return 200 status code for inbox GET endpoint', async ( { requestUtils } ) => {
- const data = await requestUtils.rest( {
- path: inboxEndpoint,
- } );
-
- expect( data ).toBeDefined();
- } );
-
- test( 'should return ActivityStreams OrderedCollection', async ( { requestUtils } ) => {
- const data = await requestUtils.rest( {
- path: inboxEndpoint,
- } );
-
- // Check for ActivityStreams context
- expect( data ).toHaveProperty( '@context' );
- expect( Array.isArray( data[ '@context' ] ) || typeof data[ '@context' ] === 'string' ).toBe( true );
-
- // Verify it's an OrderedCollection
- expect( data.type ).toBe( 'OrderedCollection' );
-
- // Check for required collection properties
- expect( data ).toHaveProperty( 'id' );
- expect( data.id ).toMatch( /^https?:\/\// );
-
- expect( data ).toHaveProperty( 'totalItems' );
- expect( typeof data.totalItems ).toBe( 'number' );
- } );
-
- test( 'should handle empty inbox collection', async ( { requestUtils } ) => {
- const data = await requestUtils.rest( {
- path: inboxEndpoint,
- } );
-
- // Inbox might be empty
- if ( data.totalItems === 0 ) {
- expect( data.orderedItems || [] ).toEqual( [] );
- }
- } );
-
- test( 'should include first property for pagination', async ( { requestUtils } ) => {
- const data = await requestUtils.rest( {
- path: inboxEndpoint,
- } );
-
- if ( data.totalItems > 0 ) {
- expect( data ).toHaveProperty( 'first' );
-
- if ( typeof data.first === 'string' ) {
- expect( data.first ).toMatch( /^https?:\/\// );
- } else if ( typeof data.first === 'object' ) {
- expect( data.first.type ).toBe( 'OrderedCollectionPage' );
- expect( data.first ).toHaveProperty( 'orderedItems' );
- }
- }
- } );
-
- test( 'should return error for non-existent user', async ( { requestUtils } ) => {
- try {
- await requestUtils.rest( {
- path: '/activitypub/1.0/users/999999/inbox',
- } );
- // If we reach here, the test should fail
- expect.fail();
- } catch ( error ) {
- // Should return 400 or 404 for invalid/non-existent user
- expect( [ 400, 404 ] ).toContain( error.status || error.code );
- }
- } );
-
- test( 'should return correct Content-Type header', async ( { requestUtils } ) => {
- const data = await requestUtils.rest( {
- path: inboxEndpoint,
- } );
-
- expect( data ).toBeDefined();
- expect( data ).toHaveProperty( 'type' );
- } );
-
- test( 'should handle page parameter', async ( { requestUtils } ) => {
- try {
- const data = await requestUtils.rest( {
- path: `${ inboxEndpoint }?page=1&per_page=10`,
- } );
-
- // If successful, verify the response structure
- expect( data.type ).toBe( 'OrderedCollectionPage' );
- } catch ( error ) {
- // Skip this test if pagination isn't available yet
- expect( error.status || error.code ).toBeGreaterThanOrEqual( 400 );
- }
- } );
-
- test( 'should validate collection structure matches ActivityStreams spec', async ( { requestUtils } ) => {
- const data = await requestUtils.rest( {
- path: inboxEndpoint,
- } );
-
- // Check for required ActivityStreams properties
- expect( data ).toHaveProperty( '@context' );
- expect( Array.isArray( data[ '@context' ] ) || typeof data[ '@context' ] === 'string' ).toBe( true );
-
- // Verify ID is a valid URL
- expect( data.id ).toMatch( /^https?:\/\// );
-
- // Verify proper typing
- expect( data.type ).toBe( 'OrderedCollection' );
- } );
-
- test( 'should validate orderedItems contain activities when present', async ( { requestUtils } ) => {
- const data = await requestUtils.rest( {
- path: inboxEndpoint,
- } );
-
- if ( data.orderedItems && data.orderedItems.length > 0 ) {
- // Each item should be an Activity or a URL to an Activity
- data.orderedItems.forEach( ( item ) => {
- if ( typeof item === 'string' ) {
- expect( item ).toMatch( /^https?:\/\// );
- } else if ( typeof item === 'object' ) {
- expect( item ).toHaveProperty( 'type' );
- // Common activity types for inbox
- const activityTypes = [
- 'Create',
- 'Update',
- 'Delete',
- 'Follow',
- 'Like',
- 'Announce',
- 'Accept',
- 'Reject',
- 'Undo',
- ];
- expect( activityTypes ).toContain( item.type );
- expect( item ).toHaveProperty( 'id' );
- }
- } );
- }
- } );
-} );
From d924ddf025bd4821816a0d20de6b6c7018aaeab2 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Tue, 3 Feb 2026 18:53:06 +0100
Subject: [PATCH 023/105] Add Application Passwords support for C2S
authentication
- Add verify_application_password() for WordPress core Application Passwords
- Add verify_authentication() that checks OAuth first, falls back to Application Passwords
- Update verify_owner() to support both OAuth tokens and WordPress users
- Simplify permission callbacks in controllers to use verify_authentication()
---
.../rest/class-actors-inbox-controller.php | 19 +---
includes/rest/class-outbox-controller.php | 19 +---
includes/rest/class-proxy-controller.php | 70 +++-----------
includes/rest/trait-verification.php | 94 ++++++++++++++-----
.../rest/class-test-proxy-controller.php | 22 +++--
5 files changed, 99 insertions(+), 125 deletions(-)
diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php
index 70aaf74ef7..e81ab3eb2c 100644
--- a/includes/rest/class-actors-inbox-controller.php
+++ b/includes/rest/class-actors-inbox-controller.php
@@ -47,7 +47,7 @@ public function register_routes() {
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_items' ),
- 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'permission_callback' => array( $this, 'verify_authentication' ),
'args' => array(
'page' => array(
'description' => 'Current page of the collection.',
@@ -111,23 +111,6 @@ public function register_routes() {
\add_action( 'activitypub_inbox_create_item', array( self::class, 'process_create_item' ) );
}
- /**
- * Permission check for reading inbox items (C2S).
- *
- * @param \WP_REST_Request $request Full details about the request.
- * @return bool|\WP_Error True if authorized, WP_Error otherwise.
- */
- public function get_items_permissions_check( $request ) {
- // Verify OAuth with read scope.
- $result = $this->verify_oauth_read( $request );
- if ( \is_wp_error( $result ) ) {
- return $result;
- }
-
- // Verify the token belongs to the requested user.
- return $this->verify_owner( $request );
- }
-
/**
* Retrieves a collection of inbox items.
*
diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php
index 8d2720a835..d391b3aa2c 100644
--- a/includes/rest/class-outbox-controller.php
+++ b/includes/rest/class-outbox-controller.php
@@ -80,7 +80,7 @@ public function register_routes() {
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'create_item' ),
- 'permission_callback' => array( $this, 'create_item_permissions_check' ),
+ 'permission_callback' => array( $this, 'verify_authentication' ),
),
'schema' => array( $this, 'get_item_schema' ),
)
@@ -327,23 +327,6 @@ public function overload_total_items( $response, $request ) {
return $response;
}
- /**
- * Permission check for creating items (C2S).
- *
- * @param \WP_REST_Request $request Full details about the request.
- * @return bool|\WP_Error True if authorized, WP_Error otherwise.
- */
- public function create_item_permissions_check( $request ) {
- // Verify OAuth with write scope.
- $result = $this->verify_oauth_write( $request );
- if ( \is_wp_error( $result ) ) {
- return $result;
- }
-
- // Verify the token belongs to the requested user.
- return $this->verify_owner( $request );
- }
-
/**
* Create an item in the outbox.
*
diff --git a/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php
index 570bb2fb03..12611a0f58 100644
--- a/includes/rest/class-proxy-controller.php
+++ b/includes/rest/class-proxy-controller.php
@@ -49,13 +49,14 @@ public function register_routes() {
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'get_item' ),
- 'permission_callback' => array( $this, 'get_item_permissions_check' ),
+ 'permission_callback' => array( $this, 'verify_authentication' ),
'args' => array(
'id' => array(
'description' => 'The URI of the remote ActivityPub object to fetch.',
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_url',
+ 'validate_callback' => array( $this, 'validate_url' ),
),
),
),
@@ -65,41 +66,23 @@ public function register_routes() {
}
/**
- * Check if the request has permission to use the proxy.
+ * Validate the URL parameter.
*
- * @param \WP_REST_Request $request Full details about the request.
- * @return true|\WP_Error True if the request has permission, WP_Error otherwise.
+ * Uses wp_http_validate_url() which blocks local/private IPs and restricts ports.
+ *
+ * @see https://developer.wordpress.org/reference/functions/wp_http_validate_url/
+ *
+ * @param string $url The URL to validate.
+ * @return bool True if valid, false otherwise.
*/
- public function get_item_permissions_check( $request ) {
- // Verify OAuth with read scope.
- $result = $this->verify_oauth_read( $request );
- if ( \is_wp_error( $result ) ) {
- return $result;
- }
-
- // Validate the URL to prevent abuse.
- $url = $request->get_param( 'id' );
-
+ public function validate_url( $url ) {
// Must be HTTPS.
if ( 'https' !== \wp_parse_url( $url, PHP_URL_SCHEME ) ) {
- return new \WP_Error(
- 'activitypub_invalid_url',
- \__( 'Only HTTPS URLs are allowed.', 'activitypub' ),
- array( 'status' => 400 )
- );
- }
-
- // Block local/private network addresses.
- $host = \wp_parse_url( $url, PHP_URL_HOST );
- if ( $this->is_private_host( $host ) ) {
- return new \WP_Error(
- 'activitypub_invalid_url',
- \__( 'Private network addresses are not allowed.', 'activitypub' ),
- array( 'status' => 400 )
- );
+ return false;
}
- return true;
+ // Use WordPress built-in validation (blocks local IPs, restricts ports).
+ return (bool) \wp_http_validate_url( $url );
}
/**
@@ -147,33 +130,6 @@ public function get_item( $request ) {
return $response;
}
- /**
- * Check if a host is a private/local network address.
- *
- * @param string $host The hostname to check.
- * @return bool True if the host is private, false otherwise.
- */
- private function is_private_host( $host ) {
- // Check for localhost.
- if ( 'localhost' === $host || '127.0.0.1' === $host || '::1' === $host ) {
- return true;
- }
-
- // Check for private IP ranges.
- $ip = \gethostbyname( $host );
- if ( $ip === $host ) {
- // DNS resolution failed, allow it (will fail on fetch anyway).
- return false;
- }
-
- // Use filter_var to check for private/reserved IPs.
- if ( false === \filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
- return true;
- }
-
- return false;
- }
-
/**
* Get the schema for the proxy endpoint.
*
diff --git a/includes/rest/trait-verification.php b/includes/rest/trait-verification.php
index 1e2f7386fa..48ab18344b 100644
--- a/includes/rest/trait-verification.php
+++ b/includes/rest/trait-verification.php
@@ -17,7 +17,7 @@
/**
* Verification Trait.
*
- * Provides methods for verifying HTTP Signatures (S2S) and OAuth tokens (C2S).
+ * Provides methods for verifying HTTP Signatures (S2S) and OAuth/Application Passwords (C2S).
* Controllers can use this trait for permission callbacks.
*/
trait Verification {
@@ -71,37 +71,83 @@ public function verify_signature( $request ) {
}
/**
- * Verify OAuth authentication with 'read' scope.
+ * Verify Application Passwords authentication.
*
- * Use this for endpoints requiring OAuth read access (C2S).
+ * Uses WordPress core Application Passwords via Basic Auth.
+ *
+ * @see https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/
*
- * @param \WP_REST_Request $request The request object.
* @return bool|\WP_Error True if authorized, WP_Error otherwise.
*/
- public function verify_oauth_read( $request ) {
- return OAuth_Server::check_oauth_permission( $request, Scope::READ );
+ public function verify_application_password() {
+ if ( \is_user_logged_in() ) {
+ return true;
+ }
+
+ return new \WP_Error(
+ 'activitypub_unauthorized',
+ \__( 'Authentication required.', 'activitypub' ),
+ array( 'status' => 401 )
+ );
}
/**
- * Verify OAuth authentication with 'write' scope.
+ * Verify user authentication via OAuth or Application Passwords.
+ *
+ * Automatically determines the required scope based on the HTTP method:
+ * - GET, HEAD: read scope
+ * - POST, PUT, PATCH, DELETE: write scope
*
- * Use this for endpoints requiring OAuth write access (C2S).
+ * If the request has a user_id parameter, also verifies that the
+ * authenticated user matches that actor.
*
* @param \WP_REST_Request $request The request object.
* @return bool|\WP_Error True if authorized, WP_Error otherwise.
*/
- public function verify_oauth_write( $request ) {
- return OAuth_Server::check_oauth_permission( $request, Scope::WRITE );
+ public function verify_authentication( $request ) {
+ // Determine scope based on HTTP method.
+ $method = $request->get_method();
+ $read_methods = array( 'GET', 'HEAD' );
+ $scope = \in_array( $method, $read_methods, true ) ? Scope::READ : Scope::WRITE;
+
+ // Try OAuth first.
+ if ( true === OAuth_Server::check_oauth_permission( $request, $scope ) ) {
+ return $this->maybe_verify_owner( $request );
+ }
+
+ // Fall back to Application Passwords.
+ $result = $this->verify_application_password();
+ if ( \is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ return $this->maybe_verify_owner( $request );
}
/**
- * Verify that the OAuth token belongs to the actor specified in the request.
+ * Verify owner if user_id parameter is present.
*
- * This checks that the user_id parameter matches the token's user.
- * Should be called after verify_oauth_read or verify_oauth_write.
+ * @param \WP_REST_Request $request The request object.
+ * @return bool|\WP_Error True if authorized, WP_Error otherwise.
+ */
+ private function maybe_verify_owner( $request ) {
+ $user_id = $request->get_param( 'user_id' );
+
+ if ( null === $user_id ) {
+ return true;
+ }
+
+ return $this->verify_owner( $request );
+ }
+
+ /**
+ * Verify that the authenticated user matches the actor specified in the request.
+ *
+ * Checks that the user_id parameter matches the OAuth token's user
+ * or the WordPress authenticated user (via Application Passwords).
*
* @param \WP_REST_Request $request The request object.
- * @return bool|\WP_Error True if the token user matches, WP_Error otherwise.
+ * @return bool|\WP_Error True if the user matches, WP_Error otherwise.
*/
public function verify_owner( $request ) {
$user_id = $request->get_param( 'user_id' );
@@ -112,17 +158,21 @@ public function verify_owner( $request ) {
return $user;
}
- // Verify the token belongs to this user.
+ // Try OAuth token first.
$token = OAuth_Server::get_current_token();
+ if ( $token && $token->get_user_id() === \absint( $user_id ) ) {
+ return true;
+ }
- if ( ! $token || $token->get_user_id() !== absint( $user_id ) ) {
- return new \WP_Error(
- 'activitypub_forbidden',
- \__( 'You can only access your own resources.', 'activitypub' ),
- array( 'status' => 403 )
- );
+ // Fall back to WordPress authenticated user (Application Passwords).
+ if ( \is_user_logged_in() && \get_current_user_id() === \absint( $user_id ) ) {
+ return true;
}
- return true;
+ return new \WP_Error(
+ 'activitypub_forbidden',
+ \__( 'You can only access your own resources.', 'activitypub' ),
+ array( 'status' => 403 )
+ );
}
}
diff --git a/tests/phpunit/tests/rest/class-test-proxy-controller.php b/tests/phpunit/tests/rest/class-test-proxy-controller.php
index df386c05fb..b9bb251f03 100644
--- a/tests/phpunit/tests/rest/class-test-proxy-controller.php
+++ b/tests/phpunit/tests/rest/class-test-proxy-controller.php
@@ -74,7 +74,7 @@ public function test_route_registered() {
/**
* Test that proxy rejects non-HTTPS URLs.
*
- * @covers ::get_item_permissions_check
+ * @covers ::validate_url
*/
public function test_http_url_rejected() {
// Mock OAuth authentication.
@@ -86,37 +86,39 @@ public function test_http_url_rejected() {
$response = $this->server->dispatch( $request );
$this->assertEquals( 400, $response->get_status() );
- $this->assertEquals( 'activitypub_invalid_url', $response->get_data()['code'] );
+ $this->assertEquals( 'rest_invalid_param', $response->get_data()['code'] );
$this->unmock_oauth_auth();
}
/**
- * Test that proxy rejects localhost URLs.
+ * Test that proxy rejects private network URLs.
*
- * @covers ::get_item_permissions_check
+ * Uses wp_http_validate_url() which blocks private IP ranges.
+ *
+ * @covers ::validate_url
*/
- public function test_localhost_rejected() {
+ public function test_private_network_rejected() {
// Mock OAuth authentication.
$this->mock_oauth_auth();
$request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' );
- $request->set_body_params( array( 'id' => 'https://localhost/users/test' ) );
+ $request->set_body_params( array( 'id' => 'https://192.168.1.1/users/test' ) );
$response = $this->server->dispatch( $request );
$this->assertEquals( 400, $response->get_status() );
- $this->assertEquals( 'activitypub_invalid_url', $response->get_data()['code'] );
+ $this->assertEquals( 'rest_invalid_param', $response->get_data()['code'] );
$this->unmock_oauth_auth();
}
/**
- * Test proxy requires OAuth authentication.
+ * Test proxy requires authentication.
*
- * @covers ::get_item_permissions_check
+ * @covers ::verify_authentication
*/
- public function test_requires_oauth() {
+ public function test_requires_authentication() {
$request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' );
$request->set_body_params( array( 'id' => 'https://example.com/users/test' ) );
From e58ce16778d5b14dfbba5559465a4627b8204250 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 4 Feb 2026 13:24:57 +0100
Subject: [PATCH 024/105] Add CORS headers to C2S endpoints
Enable browser-based C2S clients to interact with OAuth and ActivityPub
endpoints without requiring a proxy server.
Endpoints with CORS headers:
- OAuth: /token, /revoke, /introspect, /clients, /.well-known/oauth-authorization-server
- C2S: /proxy, /actors/{id}/outbox, /actors/{id}/inbox
The /oauth/authorize endpoint is excluded as it redirects to the login page.
---
includes/oauth/class-server.php | 60 +++++++++++++++++++++++++++++++++
1 file changed, 60 insertions(+)
diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php
index d6900a0484..9c5d83a560 100644
--- a/includes/oauth/class-server.php
+++ b/includes/oauth/class-server.php
@@ -29,6 +29,9 @@ 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 );
+ // Add CORS headers to OAuth endpoints.
+ \add_filter( 'rest_post_dispatch', array( self::class, 'add_cors_headers' ), 10, 3 );
+
// Schedule cleanup cron.
if ( ! \wp_next_scheduled( 'activitypub_oauth_cleanup' ) ) {
\wp_schedule_event( time(), 'daily', 'activitypub_oauth_cleanup' );
@@ -250,6 +253,63 @@ public static function cleanup() {
Authorization_Code::cleanup();
}
+ /**
+ * Add CORS headers to C2S endpoint responses.
+ *
+ * Enables browser-based C2S clients to interact with OAuth and C2S endpoints.
+ *
+ * @param \WP_REST_Response $response The response object.
+ * @param \WP_REST_Server $server The REST server instance.
+ * @param \WP_REST_Request $request The request object.
+ * @return \WP_REST_Response The modified response.
+ */
+ public static function add_cors_headers( $response, $server, $request ) {
+ $route = $request->get_route();
+
+ // Check if route needs CORS headers.
+ if ( ! self::route_needs_cors( $route ) ) {
+ return $response;
+ }
+
+ $response->header( 'Access-Control-Allow-Origin', '*' );
+ $response->header( 'Access-Control-Allow-Methods', 'GET, POST, OPTIONS' );
+ $response->header( 'Access-Control-Allow-Headers', 'Content-Type, Authorization' );
+
+ return $response;
+ }
+
+ /**
+ * Check if a route needs CORS headers.
+ *
+ * @param string $route The REST API route.
+ * @return bool True if the route needs CORS headers.
+ */
+ private static function route_needs_cors( $route ) {
+ $namespace = '/' . ACTIVITYPUB_REST_NAMESPACE;
+
+ // OAuth endpoints (except authorize which redirects).
+ if ( 0 === strpos( $route, $namespace . '/oauth' ) ) {
+ return false === strpos( $route, '/oauth/authorize' );
+ }
+
+ // Proxy endpoint for fetching remote objects.
+ if ( $namespace . '/proxy' === $route ) {
+ return true;
+ }
+
+ // C2S outbox endpoints (POST to create activities).
+ if ( preg_match( '#^' . preg_quote( $namespace, '#' ) . '/(?:users|actors)/\d+/outbox$#', $route ) ) {
+ return true;
+ }
+
+ // C2S user inbox endpoints.
+ if ( preg_match( '#^' . preg_quote( $namespace, '#' ) . '/(?:users|actors)/\d+/inbox$#', $route ) ) {
+ return true;
+ }
+
+ return false;
+ }
+
/**
* Get OAuth server metadata for discovery.
*
From 5988e328050e6ef2cff14e7ec8b010392a62efc7 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Wed, 4 Feb 2026 13:32:20 +0100
Subject: [PATCH 025/105] Add rewrite rule for OAuth Authorization Server
Metadata
Register /.well-known/oauth-authorization-server to comply with RFC 8414.
This enables OAuth clients to discover the server metadata at the
standard location.
---
includes/class-router.php | 7 +++++++
includes/rest/class-oauth-controller.php | 4 ++--
2 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/includes/class-router.php b/includes/class-router.php
index 7d14382528..f95b3c8622 100644
--- a/includes/class-router.php
+++ b/includes/class-router.php
@@ -57,6 +57,13 @@ public static function add_rewrite_rules() {
);
}
+ // Authorization Server Metadata (RFC 8414).
+ \add_rewrite_rule(
+ '^.well-known/oauth-authorization-server',
+ 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/authorization-server-metadata',
+ 'top'
+ );
+
\add_rewrite_rule( '^@([\w\-\.]+)\/?$', 'index.php?actor=$matches[1]', 'top' );
\add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES );
}
diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php
index c6d91561ee..670f6c350f 100644
--- a/includes/rest/class-oauth-controller.php
+++ b/includes/rest/class-oauth-controller.php
@@ -177,10 +177,10 @@ public function register_routes() {
)
);
- // OAuth server metadata (RFC 8414).
+ // Authorization Server Metadata (RFC 8414).
\register_rest_route(
$this->namespace,
- '/' . $this->rest_base . '/.well-known/oauth-authorization-server',
+ '/' . $this->rest_base . '/authorization-server-metadata',
array(
array(
'methods' => \WP_REST_Server::READABLE,
From 156b9a542e7d1ba28ee9d668675bf5adb484636b Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Fri, 6 Feb 2026 07:51:44 +0100
Subject: [PATCH 026/105] Optional PKCE, ActivityPub actors, loopback ports
Make PKCE optional and improve ActivityPub/client redirect handling:
- Authorization_Code::verify_pkce: Treat absence of a stored code_challenge as PKCE not used (skip verification); fail only when a challenge exists but no code_verifier is supplied.
- OAuth_Controller: Treat code_challenge as recommended (optional) and remove hard requirement/error for missing challenge/verifier to maintain compatibility with non-PKCE clients.
- Client metadata parsing: Accept ActivityPub actor types (Application, Person, Service, Group, Organization), map id/name (or preferredUsername) to client metadata, handle redirectURI/url, and set an is_actor flag for actor-based clients.
- Redirect validation: Allow RFC 8252 loopback redirect flexibility (ignore port differences for loopback hosts like 127.0.0.1, ::1, localhost) while preserving exact-match behavior for non-loopback URIs; added is_loopback_redirect_match helper.
- Server response handling: Exclude OAuth endpoints from the generic REST error format so OAuth endpoints keep RFC 6749 error responses.
These changes improve interoperability with ActivityPub actors, support permissive loopback redirect ports per RFC 8252, and maintain backward compatibility for clients that do not use PKCE.
---
includes/oauth/class-authorization-code.php | 8 ++-
includes/oauth/class-client.php | 68 +++++++++++++++++++--
includes/rest/class-oauth-controller.php | 17 +-----
includes/rest/class-server.php | 5 ++
4 files changed, 77 insertions(+), 21 deletions(-)
diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php
index 829534af20..91a6d648ac 100644
--- a/includes/oauth/class-authorization-code.php
+++ b/includes/oauth/class-authorization-code.php
@@ -176,7 +176,13 @@ public static function exchange( $code, $client_id, $redirect_uri, $code_verifie
* @return bool True if valid.
*/
public static function verify_pkce( $code_verifier, $code_challenge, $method = 'S256' ) {
- if ( empty( $code_verifier ) || empty( $code_challenge ) ) {
+ // 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;
}
diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php
index 3625c3de9b..aa54eeb199 100644
--- a/includes/oauth/class-client.php
+++ b/includes/oauth/class-client.php
@@ -310,13 +310,16 @@ private static function normalize_client_metadata( $data, $url ) {
$metadata['client_uri'] = $data['client_uri'];
}
- // ActivityPub Application format fields.
- if ( ! empty( $data['type'] ) && 'Application' === $data['type'] ) {
+ // ActivityPub actor format fields (Application, Person, Service, etc.).
+ $actor_types = array( 'Application', 'Person', 'Service', 'Group', 'Organization' );
+ if ( ! empty( $data['type'] ) && in_array( $data['type'], $actor_types, true ) ) {
if ( ! empty( $data['id'] ) ) {
$metadata['client_id'] = $data['id'];
}
if ( ! empty( $data['name'] ) ) {
$metadata['client_name'] = $data['name'];
+ } elseif ( ! empty( $data['preferredUsername'] ) ) {
+ $metadata['client_name'] = $data['preferredUsername'];
}
// Handle redirectURI (singular) as used by ap CLI.
if ( ! empty( $data['redirectURI'] ) ) {
@@ -333,6 +336,8 @@ private static function normalize_client_metadata( $data, $url ) {
if ( ! empty( $data['url'] ) ) {
$metadata['client_uri'] = is_array( $data['url'] ) ? $data['url'][0] : $data['url'];
}
+ // Mark as actor-based client for lenient redirect validation.
+ $metadata['is_actor'] = true;
}
return $metadata;
@@ -370,7 +375,7 @@ public static function validate( $client_id, $client_secret = null ) {
/**
* Check if redirect URI is valid for this client.
*
- * If explicit redirect_uris are registered, requires exact match.
+ * If explicit redirect_uris are registered, requires match (with RFC 8252 loopback handling).
* For auto-discovered clients without redirect_uris, uses same-origin policy.
*
* @param string $redirect_uri The redirect URI to validate.
@@ -379,9 +384,22 @@ public static function validate( $client_id, $client_secret = null ) {
public function is_valid_redirect_uri( $redirect_uri ) {
$allowed_uris = $this->get_redirect_uris();
- // If explicit redirect URIs are registered, require exact match.
+ // If explicit redirect URIs are registered, check for match.
if ( ! empty( $allowed_uris ) ) {
- return in_array( $redirect_uri, $allowed_uris, true );
+ // 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;
}
// For auto-discovered clients without redirect_uris, use same-origin policy.
@@ -397,6 +415,46 @@ public function is_valid_redirect_uri( $redirect_uri ) {
return false;
}
+ /**
+ * Check if two URIs match under RFC 8252 loopback rules.
+ *
+ * For loopback addresses (127.0.0.1, localhost), the port is ignored.
+ *
+ * @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.
+ $loopback_hosts = array( '127.0.0.1', 'localhost', '::1' );
+ if ( ! in_array( $allowed_host, $loopback_hosts, true ) ) {
+ // 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;
+ }
+
/**
* Get client name.
*
diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php
index 670f6c350f..6a22878f86 100644
--- a/includes/rest/class-oauth-controller.php
+++ b/includes/rest/class-oauth-controller.php
@@ -231,16 +231,8 @@ public function authorize( \WP_REST_Request $request ) {
);
}
- // Check for PKCE (required).
+ // Check for PKCE (recommended but optional for compatibility).
$code_challenge = $request->get_param( 'code_challenge' );
- if ( empty( $code_challenge ) ) {
- return $this->redirect_with_error(
- $redirect_uri,
- 'invalid_request',
- 'PKCE code_challenge is required.',
- $state
- );
- }
// Redirect to wp-login.php with action=activitypub_authorize.
// This uses WordPress's login_form_{action} hook for proper cookie auth.
@@ -407,10 +399,6 @@ private function handle_authorization_code_grant( \WP_REST_Request $request, $cl
return $this->token_error( 'invalid_request', 'Authorization code is required.' );
}
- if ( empty( $code_verifier ) ) {
- return $this->token_error( 'invalid_request', 'PKCE code_verifier is required.' );
- }
-
$result = Authorization_Code::exchange( $code, $client_id, $redirect_uri, $code_verifier );
if ( \is_wp_error( $result ) ) {
@@ -571,9 +559,8 @@ private function get_authorize_args() {
'type' => 'string',
),
'code_challenge' => array(
- 'description' => 'PKCE code challenge.',
+ 'description' => 'PKCE code challenge (recommended).',
'type' => 'string',
- 'required' => true,
),
'code_challenge_method' => array(
'description' => 'PKCE code challenge method.',
diff --git a/includes/rest/class-server.php b/includes/rest/class-server.php
index 1c5b90c47b..1d1c64e29b 100644
--- a/includes/rest/class-server.php
+++ b/includes/rest/class-server.php
@@ -121,6 +121,11 @@ public static function filter_output( $response, $server, $request ) {
return $response;
}
+ // Exclude OAuth endpoints - they have their own error format per RFC 6749.
+ if ( \str_starts_with( $route, '/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth' ) ) {
+ return $response;
+ }
+
// Only alter responses that return an error status code.
if ( $response->get_status() < 400 ) {
return $response;
From e17869f16accad2dc4ca2bc60d0d933e7312602b Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sat, 7 Feb 2026 19:13:56 +0100
Subject: [PATCH 027/105] Address C2S client feedback from PR review
- Add `me` parameter to OAuth token and introspect responses with actor URI
(IndieAuth convention) to help clients discover actor identity after auth
- Skip outbox totalItems override for authenticated requests so C2S clients
get accurate collection counts matching orderedItems
- Add migration to flush rewrite rules for .well-known/oauth-authorization-server
- Update PKCE test to reflect optional PKCE behavior (empty challenge = skip)
---
includes/class-migration.php | 4 ++++
includes/oauth/class-token.php | 17 +++++++++++++++--
includes/rest/class-outbox-controller.php | 15 ++++++++++++---
.../oauth/class-test-authorization-code.php | 11 +++++++++--
4 files changed, 40 insertions(+), 7 deletions(-)
diff --git a/includes/class-migration.php b/includes/class-migration.php
index 1ed946f9a4..93ee91eb87 100644
--- a/includes/class-migration.php
+++ b/includes/class-migration.php
@@ -219,6 +219,10 @@ public static function maybe_migrate() {
if ( \version_compare( $version_from_db, '7.9.0', '<' ) ) {
\wp_schedule_single_event( \time(), 'activitypub_migrate_actor_emoji' );
}
+ if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) {
+ // Flush rewrite rules for OAuth Authorization Server Metadata endpoint.
+ \add_action( 'init', 'flush_rewrite_rules', 20 );
+ }
// Ensure all required cron schedules are registered.
Scheduler::register_schedules();
diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php
index cc44ec3822..bdd708021f 100644
--- a/includes/oauth/class-token.php
+++ b/includes/oauth/class-token.php
@@ -7,6 +7,8 @@
namespace Activitypub\OAuth;
+use Activitypub\Collection\Actors;
+
/**
* Token class for managing OAuth 2.0 access and refresh tokens.
*
@@ -113,12 +115,17 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D
// Track user for cleanup.
self::track_user( $user_id );
+ // Get the actor URI for the 'me' parameter (IndieAuth convention).
+ $actor = Actors::get_by_id( $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( $scopes ),
+ 'me' => $me,
);
}
@@ -568,7 +575,12 @@ public static function introspect( $token ) {
return array( 'active' => false );
}
- $user = \get_userdata( $validated->get_user_id() );
+ $user_id = $validated->get_user_id();
+ $user = \get_userdata( $user_id );
+
+ // Get the actor URI for the 'me' parameter (IndieAuth convention).
+ $actor = Actors::get_by_id( $user_id );
+ $me = ! \is_wp_error( $actor ) ? $actor->get_id() : null;
return array(
'active' => true,
@@ -578,7 +590,8 @@ public static function introspect( $token ) {
'token_type' => 'Bearer',
'exp' => $validated->get_expires_at(),
'iat' => $validated->get_created_at(),
- 'sub' => (string) $validated->get_user_id(),
+ 'sub' => (string) $user_id,
+ 'me' => $me,
);
}
}
diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php
index d391b3aa2c..d4d45883b8 100644
--- a/includes/rest/class-outbox-controller.php
+++ b/includes/rest/class-outbox-controller.php
@@ -282,10 +282,14 @@ public function get_item_schema() {
}
/**
- * Overload total items.
+ * Overload total items for public requests.
*
- * The `totalItems` property is used by Mastodon to show the overall
- * number of federated posts and comments.
+ * For unauthenticated (public) requests, the `totalItems` property shows
+ * the overall number of federated posts and comments, which is what
+ * Mastodon expects for display purposes.
+ *
+ * For authenticated C2S requests, we skip this override so that totalItems
+ * accurately reflects the actual outbox collection size.
*
* @param array $response The response array.
* @param \WP_REST_Request $request The request object.
@@ -293,6 +297,11 @@ public function get_item_schema() {
* @return array The modified response array.
*/
public function overload_total_items( $response, $request ) {
+ // For authenticated requests, return accurate totalItems matching orderedItems.
+ if ( \get_current_user_id() ) {
+ return $response;
+ }
+
$posts = new \WP_Query(
array(
'post_status' => 'publish',
diff --git a/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php b/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php
index 1a4d7929c8..1b98ca9ece 100644
--- a/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php
+++ b/tests/phpunit/tests/includes/oauth/class-test-authorization-code.php
@@ -132,12 +132,19 @@ public function test_verify_pkce_plain() {
/**
* Test verify_pkce method with empty values.
*
+ * PKCE is optional: if no code_challenge was stored during authorization,
+ * verification is skipped (returns true). Only fail when a challenge exists
+ * but no verifier is provided.
+ *
* @covers ::verify_pkce
*/
public function test_verify_pkce_empty() {
+ // Challenge exists but verifier missing: should fail.
$this->assertFalse( Authorization_Code::verify_pkce( '', 'challenge', 'S256' ) );
- $this->assertFalse( Authorization_Code::verify_pkce( 'verifier', '', 'S256' ) );
- $this->assertFalse( Authorization_Code::verify_pkce( '', '', 'S256' ) );
+
+ // No challenge stored (PKCE not used): should pass (skip verification).
+ $this->assertTrue( Authorization_Code::verify_pkce( 'verifier', '', 'S256' ) );
+ $this->assertTrue( Authorization_Code::verify_pkce( '', '', 'S256' ) );
}
/**
From c897cef03bd9711ffb09cbdb142025dca0b6edf2 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sat, 7 Feb 2026 19:21:56 +0100
Subject: [PATCH 028/105] Simplify CORS route matching for outbox and inbox
endpoints
Replace complex regex patterns with simple str_ends_with checks.
Also fixes missing CORS for negative user IDs (e.g., Application actor).
---
includes/oauth/class-server.php | 9 ++-------
1 file changed, 2 insertions(+), 7 deletions(-)
diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php
index 9c5d83a560..dc5865308d 100644
--- a/includes/oauth/class-server.php
+++ b/includes/oauth/class-server.php
@@ -297,13 +297,8 @@ private static function route_needs_cors( $route ) {
return true;
}
- // C2S outbox endpoints (POST to create activities).
- if ( preg_match( '#^' . preg_quote( $namespace, '#' ) . '/(?:users|actors)/\d+/outbox$#', $route ) ) {
- return true;
- }
-
- // C2S user inbox endpoints.
- if ( preg_match( '#^' . preg_quote( $namespace, '#' ) . '/(?:users|actors)/\d+/inbox$#', $route ) ) {
+ // C2S outbox and inbox endpoints.
+ if ( \str_ends_with( $route, '/outbox' ) || \str_ends_with( $route, '/inbox' ) ) {
return true;
}
From 550da0236efea8fc54a392aaf86cd38fa499d99e Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 8 Feb 2026 09:49:15 +0100
Subject: [PATCH 029/105] Add CORS headers to ActivityPub JSON responses for
profile URLs
Enable C2S clients to fetch actor profiles directly from the browser
by adding CORS headers when serving ActivityPub JSON via template
rendering (e.g., /?author=1 or /author/username/).
---
includes/class-router.php | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/includes/class-router.php b/includes/class-router.php
index f95b3c8622..43d71bdad9 100644
--- a/includes/class-router.php
+++ b/includes/class-router.php
@@ -97,6 +97,7 @@ public static function render_activitypub_template( $template ) {
if ( ! $activitypub_object ) {
\status_header( 410 );
}
+ self::add_cors_headers();
return ACTIVITYPUB_PLUGIN_DIR . 'templates/tombstone-json.php';
}
@@ -142,6 +143,7 @@ public static function render_activitypub_template( $template ) {
\status_header( 200 );
}
+ self::add_cors_headers();
return $activitypub_template;
}
@@ -175,6 +177,22 @@ static function () use ( $id ) {
);
}
+ /**
+ * Add CORS headers for ActivityPub JSON responses.
+ *
+ * This enables C2S clients to fetch actor profiles and other
+ * ActivityPub objects directly from the browser.
+ */
+ private static function add_cors_headers() {
+ if ( \headers_sent() ) {
+ return;
+ }
+
+ \header( 'Access-Control-Allow-Origin: *' );
+ \header( 'Access-Control-Allow-Methods: GET, OPTIONS' );
+ \header( 'Access-Control-Allow-Headers: Accept, Authorization, Content-Type' );
+ }
+
/**
* Remove trailing slash from ActivityPub @username requests.
*
From 8e70b689d333197ecc7e27302273f40a9a128c90 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 8 Feb 2026 09:55:08 +0100
Subject: [PATCH 030/105] Address Copilot code review feedback for C2S OAuth
implementation
- Fix OAuth scope bypass: Don't fall back to Application Passwords when
OAuth auth succeeds but scope check fails (security fix)
- Fix SQL cleanup query: Use OR instead of AND for transient/timeout
prefixes since a single option_name can't match both
- Fix Cancel button: Submit form as denial to return proper OAuth error
to client's redirect_uri instead of navigating to home
- Escape dot in .well-known regex: Use \\. to match literal dot
- Fix IPv6 loopback check: Add '::1' since parse_url strips brackets
---
includes/class-router.php | 2 +-
includes/oauth/class-authorization-code.php | 2 +-
includes/oauth/class-client.php | 4 +++-
includes/rest/trait-verification.php | 11 +++++++++--
templates/oauth-authorize.php | 4 ++--
5 files changed, 16 insertions(+), 7 deletions(-)
diff --git a/includes/class-router.php b/includes/class-router.php
index 43d71bdad9..956e88d641 100644
--- a/includes/class-router.php
+++ b/includes/class-router.php
@@ -59,7 +59,7 @@ public static function add_rewrite_rules() {
// Authorization Server Metadata (RFC 8414).
\add_rewrite_rule(
- '^.well-known/oauth-authorization-server',
+ '^\\.well-known/oauth-authorization-server',
'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/oauth/authorization-server-metadata',
'top'
);
diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php
index 91a6d648ac..3878ced081 100644
--- a/includes/oauth/class-authorization-code.php
+++ b/includes/oauth/class-authorization-code.php
@@ -244,7 +244,7 @@ public static function cleanup() {
$wpdb->prepare(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE %s
- AND option_name LIKE %s",
+ OR option_name LIKE %s",
$wpdb->esc_like( '_transient_' . self::TRANSIENT_PREFIX ) . '%',
$wpdb->esc_like( '_transient_timeout_' . self::TRANSIENT_PREFIX ) . '%'
)
diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php
index aa54eeb199..bda2237479 100644
--- a/includes/oauth/class-client.php
+++ b/includes/oauth/class-client.php
@@ -589,7 +589,9 @@ private static function validate_uri_format( $uri ) {
// Allow http for localhost development.
if ( 'http' === $parsed['scheme'] ) {
- $localhost_hosts = array( 'localhost', '127.0.0.1', '[::1]' );
+ // Include both bracketed and unbracketed IPv6 loopback since parse_url
+ // may return either format depending on PHP version.
+ $localhost_hosts = array( 'localhost', '127.0.0.1', '[::1]', '::1' );
if ( ! in_array( $parsed['host'], $localhost_hosts, true ) ) {
return false;
}
diff --git a/includes/rest/trait-verification.php b/includes/rest/trait-verification.php
index 48ab18344b..5eba04ee37 100644
--- a/includes/rest/trait-verification.php
+++ b/includes/rest/trait-verification.php
@@ -111,11 +111,18 @@ public function verify_authentication( $request ) {
$scope = \in_array( $method, $read_methods, true ) ? Scope::READ : Scope::WRITE;
// Try OAuth first.
- if ( true === OAuth_Server::check_oauth_permission( $request, $scope ) ) {
+ $oauth_result = OAuth_Server::check_oauth_permission( $request, $scope );
+ if ( true === $oauth_result ) {
return $this->maybe_verify_owner( $request );
}
- // Fall back to Application Passwords.
+ // If OAuth was attempted (Bearer token present), don't fall back to Application Passwords.
+ // This prevents scope bypass when OAuth auth succeeds but scope check fails.
+ if ( \is_wp_error( $oauth_result ) && OAuth_Server::is_oauth_request() ) {
+ return $oauth_result;
+ }
+
+ // Fall back to Application Passwords only when no OAuth token was used.
$result = $this->verify_application_password();
if ( \is_wp_error( $result ) ) {
return $result;
diff --git a/templates/oauth-authorize.php b/templates/oauth-authorize.php
index 13324adb5d..b7fea45ed2 100644
--- a/templates/oauth-authorize.php
+++ b/templates/oauth-authorize.php
@@ -104,9 +104,9 @@
-
+
+
From 1caa499d1eec7688bee5f3235acf3be2ed0c99e2 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 8 Feb 2026 09:56:39 +0100
Subject: [PATCH 031/105] Escape dots in webfinger and nodeinfo rewrite rules
for consistency
---
includes/class-router.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/includes/class-router.php b/includes/class-router.php
index 956e88d641..16fd886306 100644
--- a/includes/class-router.php
+++ b/includes/class-router.php
@@ -43,7 +43,7 @@ public static function add_rewrite_rules() {
if ( ! \class_exists( 'Webfinger' ) ) {
\add_rewrite_rule(
- '^.well-known/webfinger',
+ '^\\.well-known/webfinger',
'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/webfinger',
'top'
);
@@ -51,7 +51,7 @@ public static function add_rewrite_rules() {
if ( ! \class_exists( 'Nodeinfo_Endpoint' ) && true === (bool) \get_option( 'blog_public', 1 ) ) {
\add_rewrite_rule(
- '^.well-known/nodeinfo',
+ '^\\.well-known/nodeinfo',
'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/nodeinfo',
'top'
);
From 3e8ae7002c6057f561337e20ee7d7ac7198edf6f Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 8 Feb 2026 10:14:47 +0100
Subject: [PATCH 032/105] Address additional Copilot review feedback
- Token validation: Use direct DB lookup by meta_key (O(1) instead of O(n))
- Token revoke: Clean up tracked users list when last token is revoked
- Inbox controller: Add WP_Error check before using $user
- Outbox controller: Add WP_Error check before using $user
- Outbox controller: Clarify handler return value contract (int, false, array)
- OAuth server: Reset $current_token at start of auth to prevent state leak
- Auth code cleanup: Only delete expired transients, not in-progress ones
---
includes/oauth/class-authorization-code.php | 44 ++++++++--
includes/oauth/class-server.php | 4 +
includes/oauth/class-token.php | 88 +++++++++++++------
.../rest/class-actors-inbox-controller.php | 4 +
includes/rest/class-outbox-controller.php | 22 ++++-
5 files changed, 124 insertions(+), 38 deletions(-)
diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php
index 3878ced081..a14345f1b3 100644
--- a/includes/oauth/class-authorization-code.php
+++ b/includes/oauth/class-authorization-code.php
@@ -230,6 +230,9 @@ public static function hash_code( $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.
*
@@ -238,18 +241,43 @@ public static function hash_code( $code ) {
public static function cleanup() {
global $wpdb;
- // Clean up expired transients with our prefix.
- // Transients should auto-expire, but this catches edge cases.
- $count = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $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(
- "DELETE FROM {$wpdb->options}
+ "SELECT option_name FROM {$wpdb->options}
WHERE option_name LIKE %s
- OR option_name LIKE %s",
- $wpdb->esc_like( '_transient_' . self::TRANSIENT_PREFIX ) . '%',
- $wpdb->esc_like( '_transient_timeout_' . self::TRANSIENT_PREFIX ) . '%'
+ 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
- return $count ? (int) ( $count / 2 ) : 0; // Each transient has 2 rows.
+ // Each transient has 2 rows (value + timeout).
+ return $count ? (int) ( $count / 2 ) : 0;
}
}
diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php
index dc5865308d..9d5819c060 100644
--- a/includes/oauth/class-server.php
+++ b/includes/oauth/class-server.php
@@ -46,6 +46,10 @@ public static function init() {
* @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;
+
// If another authentication method already succeeded, use that.
if ( true === $result || \is_user_logged_in() ) {
return $result;
diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php
index bdd708021f..dca44cbb79 100644
--- a/includes/oauth/class-token.php
+++ b/includes/oauth/class-token.php
@@ -136,43 +136,61 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D
* @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;
- // Search for the token across all users with tokens.
- $users = self::get_tracked_users();
+ // 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
+ )
+ );
- foreach ( $users as $user_id ) {
- $token_data = \get_user_meta( $user_id, $meta_key, true );
+ if ( empty( $user_id ) ) {
+ return new \WP_Error(
+ 'activitypub_invalid_token',
+ \__( 'Invalid access token.', 'activitypub' ),
+ array( 'status' => 401 )
+ );
+ }
- if ( ! empty( $token_data ) && is_array( $token_data ) ) {
- // Verify hash matches.
- if ( isset( $token_data['access_token_hash'] ) &&
- hash_equals( $token_data['access_token_hash'], $token_hash ) ) {
+ $token_data = \get_user_meta( (int) $user_id, $meta_key, true );
- // 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 )
- );
- }
+ if ( empty( $token_data ) || ! is_array( $token_data ) ) {
+ return new \WP_Error(
+ 'activitypub_invalid_token',
+ \__( 'Invalid access token.', 'activitypub' ),
+ array( 'status' => 401 )
+ );
+ }
- // Update last used timestamp.
- $token_data['last_used_at'] = time();
- \update_user_meta( $user_id, $meta_key, $token_data );
+ // 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 )
+ );
+ }
- return new self( $user_id, $token_hash, $token_data );
- }
- }
+ // 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 )
+ );
}
- return new \WP_Error(
- 'activitypub_invalid_token',
- \__( 'Invalid access token.', 'activitypub' ),
- array( 'status' => 401 )
- );
+ // Update last used timestamp.
+ $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 );
}
/**
@@ -254,7 +272,9 @@ public static function revoke( $token ) {
$users = self::get_tracked_users();
foreach ( $users as $user_id ) {
- $all_meta = \get_user_meta( $user_id );
+ $all_meta = \get_user_meta( $user_id );
+ $remaining_count = 0;
+ $found_token = false;
foreach ( $all_meta as $meta_key => $meta_values ) {
if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
@@ -274,8 +294,18 @@ public static function revoke( $token ) {
hash_equals( $token_data['refresh_token_hash'], $token_hash ) ) ) {
\delete_user_meta( $user_id, $meta_key );
- return true;
+ $found_token = true;
+ } else {
+ ++$remaining_count;
+ }
+ }
+
+ if ( $found_token ) {
+ // Untrack user if no remaining tokens.
+ if ( 0 === $remaining_count ) {
+ self::untrack_user( $user_id );
}
+ return true;
}
}
diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php
index e81ab3eb2c..ed945b3329 100644
--- a/includes/rest/class-actors-inbox-controller.php
+++ b/includes/rest/class-actors-inbox-controller.php
@@ -122,6 +122,10 @@ public function get_items( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = Actors::get_by_id( $user_id );
+ if ( \is_wp_error( $user ) ) {
+ return $user;
+ }
+
/**
* Action triggered prior to the ActivityPub inbox being created and sent to the client.
*
diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php
index d4d45883b8..cf560e7ff6 100644
--- a/includes/rest/class-outbox-controller.php
+++ b/includes/rest/class-outbox-controller.php
@@ -348,7 +348,12 @@ public function overload_total_items( $response, $request ) {
public function create_item( $request ) {
$user_id = $request->get_param( 'user_id' );
$user = Actors::get_by_id( $user_id );
- $data = $request->get_json_params();
+
+ if ( \is_wp_error( $user ) ) {
+ return $user;
+ }
+
+ $data = $request->get_json_params();
if ( empty( $data ) ) {
return new \WP_Error(
@@ -383,7 +388,10 @@ public function create_item( $request ) {
*
* Handlers can process the activity and return:
* - WP_Post: A WordPress post was created (scheduler adds to outbox)
+ * - int: An outbox post ID (activity already added to outbox)
* - WP_Error: Stop processing and return error
+ * - false: Stop processing (activity not allowed)
+ * - array: Modified activity data (fallback to default handling)
* - Other: No handler processed the activity (fallback to default)
*
* @param array $data The activity data.
@@ -396,11 +404,23 @@ public function create_item( $request ) {
return $result;
}
+ // Handler returned false to signal "not allowed" or "stop processing".
+ if ( false === $result ) {
+ return new \WP_Error(
+ 'activitypub_activity_not_allowed',
+ \__( 'This activity type is not allowed.', 'activitypub' ),
+ array( 'status' => 403 )
+ );
+ }
+
// If handler returned a WP_Post, the scheduler already added it to outbox.
if ( $result instanceof \WP_Post ) {
$object_id = \Activitypub\get_post_id( $result->ID );
$activity_type = \ucfirst( $data['type'] ?? 'Create' );
$outbox_item = Outbox::get_by_object_id( $object_id, $activity_type );
+ } elseif ( \is_int( $result ) && $result > 0 ) {
+ // Handler returned an outbox post ID directly.
+ $outbox_item = \get_post( $result );
} else {
// Default handling for raw activities.
$data = \is_array( $result ) ? $result : $data;
From aa52626a058218feb2ae1ab5ed6bd17dd13858fb Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 8 Feb 2026 10:59:47 +0100
Subject: [PATCH 033/105] Remove deprecated is_c2s_enabled method
Delete the deprecated Server::is_c2s_enabled() method and its docblock. The method always returned true (C2S is now always enabled), so this removes obsolete code and cleans up the class.
---
includes/oauth/class-server.php | 11 -----------
1 file changed, 11 deletions(-)
diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php
index 9d5819c060..b05864eace 100644
--- a/includes/oauth/class-server.php
+++ b/includes/oauth/class-server.php
@@ -235,17 +235,6 @@ public static function check_oauth_permission( $request, $scope = null ) {
return true;
}
- /**
- * Check if C2S (Client-to-Server) is enabled.
- *
- * @deprecated C2S is now always enabled.
- *
- * @return bool Always returns true.
- */
- public static function is_c2s_enabled() {
- return true;
- }
-
/**
* Run cleanup tasks for OAuth data.
*/
From bfdb8d57c01445f1dd261541a87839431f9c50a8 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 8 Feb 2026 11:06:28 +0100
Subject: [PATCH 034/105] Address third round of Copilot review feedback
- Token create: Return validated scopes in response, not original input
- Token refresh: Add O(1) lookup via refresh token index instead of O(n) scan
- Token revoke: Use O(1) lookups and clean up refresh indices
- Inbox controller: Add deprecated hook for backward compatibility
- Proxy controller: Use read scope instead of write (it's a read operation)
---
includes/oauth/class-token.php | 245 ++++++++++++------
.../rest/class-actors-inbox-controller.php | 8 +
includes/rest/class-proxy-controller.php | 29 ++-
3 files changed, 200 insertions(+), 82 deletions(-)
diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php
index dca44cbb79..7cfb56f6b6 100644
--- a/includes/oauth/class-token.php
+++ b/includes/oauth/class-token.php
@@ -21,6 +21,11 @@ class Token {
*/
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_';
+
/**
* Option key for tracking users with tokens (for cleanup).
*/
@@ -101,8 +106,9 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D
);
// Store in user meta with access token hash as key.
- $meta_key = self::META_PREFIX . self::hash_token( $access_token );
- $result = \update_user_meta( $user_id, $meta_key, $token_data );
+ $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(
@@ -112,6 +118,10 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D
);
}
+ // 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 for cleanup.
self::track_user( $user_id );
@@ -124,7 +134,7 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D
'token_type' => 'Bearer',
'expires_in' => $expires,
'refresh_token' => $refresh_token,
- 'scope' => Scope::to_string( $scopes ),
+ 'scope' => Scope::to_string( $token_data['scopes'] ),
'me' => $me,
);
}
@@ -201,64 +211,90 @@ public static function validate( $token ) {
* @return array|\WP_Error New token data or error.
*/
public static function refresh( $refresh_token, $client_id ) {
- $refresh_hash = self::hash_token( $refresh_token );
- $users = self::get_tracked_users();
+ global $wpdb;
- foreach ( $users as $user_id ) {
- // Get all token meta for this user.
- $all_meta = \get_user_meta( $user_id );
+ $refresh_hash = self::hash_token( $refresh_token );
+ $refresh_index_key = self::REFRESH_INDEX_PREFIX . $refresh_hash;
- foreach ( $all_meta as $meta_key => $meta_values ) {
- if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
- continue;
- }
+ // 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
+ )
+ );
- $token_data = maybe_unserialize( $meta_values[0] );
+ if ( empty( $user_id ) ) {
+ return new \WP_Error(
+ 'activitypub_invalid_refresh_token',
+ \__( 'Invalid refresh token.', 'activitypub' ),
+ array( 'status' => 401 )
+ );
+ }
- if ( ! is_array( $token_data ) ) {
- continue;
- }
+ $user_id = (int) $user_id;
- // Check if this is our refresh token.
- if ( isset( $token_data['refresh_token_hash'] ) &&
- hash_equals( $token_data['refresh_token_hash'], $refresh_hash ) ) {
-
- // 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 )
- );
- }
+ // 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 )
+ );
+ }
- // Check refresh token expiration.
- if ( isset( $token_data['refresh_expires_at'] ) &&
- $token_data['refresh_expires_at'] < time() ) {
- // Delete the expired token.
- \delete_user_meta( $user_id, $meta_key );
-
- return new \WP_Error(
- 'activitypub_refresh_token_expired',
- \__( 'Refresh token has expired.', '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 );
- // Delete the old token.
- \delete_user_meta( $user_id, $meta_key );
+ if ( empty( $token_data ) || ! is_array( $token_data ) ) {
+ return new \WP_Error(
+ 'activitypub_invalid_refresh_token',
+ \__( 'Invalid refresh token.', 'activitypub' ),
+ array( 'status' => 401 )
+ );
+ }
- // Create a new token.
- return self::create( $user_id, $client_id, $token_data['scopes'] );
- }
- }
+ // 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 )
+ );
}
- 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'] );
}
/**
@@ -268,51 +304,85 @@ public static function refresh( $refresh_token, $client_id ) {
* @return bool True on success (always returns true per RFC 7009).
*/
public static function revoke( $token ) {
+ global $wpdb;
+
$token_hash = self::hash_token( $token );
- $users = self::get_tracked_users();
- foreach ( $users as $user_id ) {
- $all_meta = \get_user_meta( $user_id );
- $remaining_count = 0;
- $found_token = false;
+ // 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
+ )
+ );
- foreach ( $all_meta as $meta_key => $meta_values ) {
- if ( 0 !== strpos( $meta_key, self::META_PREFIX ) ) {
- continue;
- }
+ if ( $user_id ) {
+ $user_id = (int) $user_id;
+ $token_data = \get_user_meta( $user_id, $access_meta_key, true );
- $token_data = maybe_unserialize( $meta_values[0] );
+ // Delete the token.
+ \delete_user_meta( $user_id, $access_meta_key );
- if ( ! is_array( $token_data ) ) {
- continue;
- }
+ // 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 );
+ }
- // Check both access and refresh token hashes.
- if ( ( isset( $token_data['access_token_hash'] ) &&
- hash_equals( $token_data['access_token_hash'], $token_hash ) ) ||
- ( isset( $token_data['refresh_token_hash'] ) &&
- hash_equals( $token_data['refresh_token_hash'], $token_hash ) ) ) {
+ self::maybe_untrack_user( $user_id );
+ return true;
+ }
- \delete_user_meta( $user_id, $meta_key );
- $found_token = true;
- } else {
- ++$remaining_count;
- }
- }
+ // 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 ( $found_token ) {
- // Untrack user if no remaining tokens.
- if ( 0 === $remaining_count ) {
- self::untrack_user( $user_id );
- }
- return true;
+ if ( $user_id ) {
+ $user_id = (int) $user_id;
+ $access_hash = \get_user_meta( $user_id, $refresh_index_key, true );
+
+ // Delete the token and index.
+ 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 );
+ return true;
}
// Token doesn't exist or already revoked - that's fine per RFC 7009.
return true;
}
+ /**
+ * Untrack user if they have no remaining tokens.
+ *
+ * @param int $user_id The user ID.
+ */
+ private static function maybe_untrack_user( $user_id ) {
+ global $wpdb;
+
+ // Check if user has any remaining tokens.
+ $count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM $wpdb->usermeta WHERE user_id = %d AND meta_key LIKE %s",
+ $user_id,
+ $wpdb->esc_like( self::META_PREFIX ) . '%'
+ )
+ );
+
+ if ( 0 === (int) $count ) {
+ self::untrack_user( $user_id );
+ }
+ }
+
/**
* Revoke all tokens for a user.
*
@@ -324,10 +394,15 @@ public static function revoke_all_for_user( $user_id ) {
$count = 0;
foreach ( $all_meta as $meta_key => $meta_values ) {
+ // Delete token entries.
if ( 0 === strpos( $meta_key, self::META_PREFIX ) ) {
\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 tracking if no more tokens.
@@ -366,6 +441,10 @@ public static function revoke_for_client( $client_id ) {
// Check if this token belongs to the 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;
} else {
++$user_tokens;
@@ -576,6 +655,10 @@ public static function cleanup_expired() {
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;
} else {
++$user_tokens;
diff --git a/includes/rest/class-actors-inbox-controller.php b/includes/rest/class-actors-inbox-controller.php
index ed945b3329..542c91b4e4 100644
--- a/includes/rest/class-actors-inbox-controller.php
+++ b/includes/rest/class-actors-inbox-controller.php
@@ -199,6 +199,14 @@ public function get_items( $request ) {
*/
\do_action( 'activitypub_rest_inbox_post', $request );
+ // Fire deprecated hook for backward compatibility.
+ \do_action_deprecated(
+ 'activitypub_inbox_post',
+ array( $request ),
+ '4.8.0',
+ 'activitypub_rest_inbox_post'
+ );
+
$response = \rest_ensure_response( $response );
$response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
diff --git a/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php
index 12611a0f58..a870a452f0 100644
--- a/includes/rest/class-proxy-controller.php
+++ b/includes/rest/class-proxy-controller.php
@@ -12,6 +12,8 @@
use Activitypub\Collection\Remote_Actors;
use Activitypub\Http;
+use Activitypub\OAuth\Scope;
+use Activitypub\OAuth\Server as OAuth_Server;
use function Activitypub\is_actor;
@@ -49,7 +51,7 @@ public function register_routes() {
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'get_item' ),
- 'permission_callback' => array( $this, 'verify_authentication' ),
+ 'permission_callback' => array( $this, 'verify_read_permission' ),
'args' => array(
'id' => array(
'description' => 'The URI of the remote ActivityPub object to fetch.',
@@ -85,6 +87,31 @@ public function validate_url( $url ) {
return (bool) \wp_http_validate_url( $url );
}
+ /**
+ * Verify read permission for proxy endpoint.
+ *
+ * The proxy is a read operation (fetching remote objects) even though it uses POST.
+ * This ensures clients with only read scope can use the proxy.
+ *
+ * @param \WP_REST_Request $request The request object.
+ * @return bool|\WP_Error True if authorized, WP_Error otherwise.
+ */
+ public function verify_read_permission( $request ) {
+ // Try OAuth with read scope.
+ $oauth_result = OAuth_Server::check_oauth_permission( $request, Scope::READ );
+ if ( true === $oauth_result ) {
+ return true;
+ }
+
+ // If OAuth was attempted but failed, don't fall back.
+ if ( \is_wp_error( $oauth_result ) && OAuth_Server::is_oauth_request() ) {
+ return $oauth_result;
+ }
+
+ // Fall back to Application Passwords.
+ return $this->verify_application_password();
+ }
+
/**
* Fetch a remote ActivityPub object via the proxy.
*
From 8731b1df194b31d3d4a8fda1edc4afc9f237acd5 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 8 Feb 2026 11:10:49 +0100
Subject: [PATCH 035/105] Change proxy endpoint to GET for proper read scope
inference
---
includes/rest/class-proxy-controller.php | 31 ++-----------------
.../rest/class-test-proxy-controller.php | 16 +++++-----
2 files changed, 10 insertions(+), 37 deletions(-)
diff --git a/includes/rest/class-proxy-controller.php b/includes/rest/class-proxy-controller.php
index a870a452f0..4d9b6c4193 100644
--- a/includes/rest/class-proxy-controller.php
+++ b/includes/rest/class-proxy-controller.php
@@ -12,8 +12,6 @@
use Activitypub\Collection\Remote_Actors;
use Activitypub\Http;
-use Activitypub\OAuth\Scope;
-use Activitypub\OAuth\Server as OAuth_Server;
use function Activitypub\is_actor;
@@ -49,9 +47,9 @@ public function register_routes() {
'/' . $this->rest_base,
array(
array(
- 'methods' => \WP_REST_Server::CREATABLE,
+ 'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
- 'permission_callback' => array( $this, 'verify_read_permission' ),
+ 'permission_callback' => array( $this, 'verify_authentication' ),
'args' => array(
'id' => array(
'description' => 'The URI of the remote ActivityPub object to fetch.',
@@ -87,31 +85,6 @@ public function validate_url( $url ) {
return (bool) \wp_http_validate_url( $url );
}
- /**
- * Verify read permission for proxy endpoint.
- *
- * The proxy is a read operation (fetching remote objects) even though it uses POST.
- * This ensures clients with only read scope can use the proxy.
- *
- * @param \WP_REST_Request $request The request object.
- * @return bool|\WP_Error True if authorized, WP_Error otherwise.
- */
- public function verify_read_permission( $request ) {
- // Try OAuth with read scope.
- $oauth_result = OAuth_Server::check_oauth_permission( $request, Scope::READ );
- if ( true === $oauth_result ) {
- return true;
- }
-
- // If OAuth was attempted but failed, don't fall back.
- if ( \is_wp_error( $oauth_result ) && OAuth_Server::is_oauth_request() ) {
- return $oauth_result;
- }
-
- // Fall back to Application Passwords.
- return $this->verify_application_password();
- }
-
/**
* Fetch a remote ActivityPub object via the proxy.
*
diff --git a/tests/phpunit/tests/rest/class-test-proxy-controller.php b/tests/phpunit/tests/rest/class-test-proxy-controller.php
index b9bb251f03..3d9a70f9c2 100644
--- a/tests/phpunit/tests/rest/class-test-proxy-controller.php
+++ b/tests/phpunit/tests/rest/class-test-proxy-controller.php
@@ -80,8 +80,8 @@ public function test_http_url_rejected() {
// Mock OAuth authentication.
$this->mock_oauth_auth();
- $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' );
- $request->set_body_params( array( 'id' => 'http://example.com/users/test' ) );
+ $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' );
+ $request->set_query_params( array( 'id' => 'http://example.com/users/test' ) );
$response = $this->server->dispatch( $request );
@@ -102,8 +102,8 @@ public function test_private_network_rejected() {
// Mock OAuth authentication.
$this->mock_oauth_auth();
- $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' );
- $request->set_body_params( array( 'id' => 'https://192.168.1.1/users/test' ) );
+ $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' );
+ $request->set_query_params( array( 'id' => 'https://192.168.1.1/users/test' ) );
$response = $this->server->dispatch( $request );
@@ -119,8 +119,8 @@ public function test_private_network_rejected() {
* @covers ::verify_authentication
*/
public function test_requires_authentication() {
- $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' );
- $request->set_body_params( array( 'id' => 'https://example.com/users/test' ) );
+ $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' );
+ $request->set_query_params( array( 'id' => 'https://example.com/users/test' ) );
$response = $this->server->dispatch( $request );
@@ -158,8 +158,8 @@ function () use ( $actor_data ) {
}
);
- $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' );
- $request->set_body_params( array( 'id' => 'https://example.com/users/test' ) );
+ $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/proxy' );
+ $request->set_query_params( array( 'id' => 'https://example.com/users/test' ) );
$response = $this->server->dispatch( $request );
From 90971cd6ccdc377a4b9cf93a6a24a123c76f67ef Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Sun, 8 Feb 2026 15:57:18 +0100
Subject: [PATCH 036/105] Use post permalink as object ID instead of generating
/objects/uuid
When a C2S client submits a Create activity with an object that has no ID,
the server now creates a WordPress post and uses its permalink as the object
ID. Previously, a non-dereferenceable /objects/{uuid} URL was generated.
The ensure_object_id() method now only sets attributedTo and published
fields, leaving object ID assignment to handlers that create WordPress
content (like Create::outgoing for Note/Article types).
---
includes/rest/class-outbox-controller.php | 17 +++----
.../rest/class-test-outbox-controller.php | 46 +++++++++++++++++++
2 files changed, 52 insertions(+), 11 deletions(-)
diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php
index cf560e7ff6..2e5147d52f 100644
--- a/includes/rest/class-outbox-controller.php
+++ b/includes/rest/class-outbox-controller.php
@@ -554,29 +554,24 @@ private function determine_visibility( $activity ) {
}
/**
- * Ensure the activity object has an ID.
+ * Ensure the activity object has required fields.
*
- * For C2S activities, clients may not provide object IDs.
- * The server must generate them.
+ * For C2S activities, clients may not provide all required fields.
+ * The server should fill in attributedTo and published, but object IDs
+ * should only be set by handlers that create WordPress content.
*
* @param array $data The activity data.
* @param \Activitypub\Model\User|null $user The authenticated user.
- * @return array The activity data with object ID ensured.
+ * @return array The activity data with required fields ensured.
*/
private function ensure_object_id( $data, $user ) {
- // Check if there's an embedded object that needs an ID.
+ // Check if there's an embedded object that needs fields.
if ( ! isset( $data['object'] ) || ! is_array( $data['object'] ) ) {
return $data;
}
$object = &$data['object'];
- // Generate ID if missing.
- if ( empty( $object['id'] ) ) {
- $uuid = \wp_generate_uuid4();
- $object['id'] = get_rest_url_by_path( 'objects/' . $uuid );
- }
-
// Set attributedTo if missing.
if ( empty( $object['attributedTo'] ) && $user ) {
$object['attributedTo'] = $user->get_id();
diff --git a/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php b/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php
index 59c328c6dc..9387868e24 100644
--- a/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php
+++ b/tests/phpunit/tests/includes/rest/class-test-outbox-controller.php
@@ -647,4 +647,50 @@ public function test_get_item() {
public function test_get_item_schema() {
// Controller does not implement get_item_schema().
}
+
+ /**
+ * Test C2S POST creates Note with proper object ID.
+ *
+ * When a client submits a Create activity with an object that has no ID,
+ * the server should create a WordPress post and use its permalink as the
+ * object ID (not generate a random /objects/uuid URL).
+ *
+ * @covers ::create_item
+ */
+ public function test_c2s_create_note_object_id() {
+ $user = \Activitypub\Collection\Actors::get_by_id( self::$user_id );
+
+ $data = array(
+ 'type' => 'Create',
+ 'actor' => $user->get_id(),
+ 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ),
+ 'object' => array(
+ 'type' => 'Note',
+ 'content' => 'Hello from C2S test!',
+ // No ID provided - server should set it to the post permalink.
+ ),
+ );
+
+ $request = new \WP_REST_Request( 'POST', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . self::$user_id . '/outbox' );
+ $request->set_header( 'Content-Type', 'application/activity+json' );
+ $request->set_body( \wp_json_encode( $data ) );
+
+ \wp_set_current_user( self::$user_id );
+
+ $response = \rest_get_server()->dispatch( $request );
+
+ $this->assertEquals( 201, $response->get_status() );
+
+ $response_data = $response->get_data();
+
+ // The object should have an ID that's a post permalink, not /objects/uuid.
+ $this->assertArrayHasKey( 'object', $response_data );
+ $object = $response_data['object'];
+
+ if ( is_array( $object ) ) {
+ $this->assertArrayHasKey( 'id', $object );
+ $this->assertStringNotContainsString( '/objects/', $object['id'], 'Object ID should not be a /objects/uuid URL' );
+ $this->assertStringContainsString( '?p=', $object['id'], 'Object ID should be a post permalink' );
+ }
+ }
}
From d41a76740d3df2eda8cc0cb6a2dbb74d0aaf9bb3 Mon Sep 17 00:00:00 2001
From: Matthias Pfefferle
Date: Mon, 9 Feb 2026 18:12:53 +0100
Subject: [PATCH 037/105] Fix review issues and add outgoing handler tests.
Address PR review feedback: guard Update handler against recursion,
use (int) cast instead of absint() for negative actor IDs, switch to
wp_redirect() for external OAuth URIs, require PKCE for public clients,
add auth to introspection endpoint, remove dead render_consent_page(),
convert multiline // comments to block comments, and add 24 tests
covering all outgoing handler methods.
---
includes/handler/class-delete.php | 6 +-
includes/handler/class-update.php | 29 +-
includes/oauth/class-authorization-code.php | 9 +
includes/oauth/class-client.php | 18 +-
includes/oauth/class-server.php | 19 +-
includes/rest/class-oauth-controller.php | 126 ++-------
includes/rest/trait-verification.php | 10 +-
includes/scheduler/class-post.php | 6 +-
.../includes/handler/class-test-announce.php | 53 ++++
.../includes/handler/class-test-delete.php | 126 +++++++++
.../includes/handler/class-test-follow.php | 114 ++++++++
.../includes/handler/class-test-like.php | 53 ++++
.../includes/handler/class-test-undo.php | 105 +++++++
.../includes/handler/class-test-update.php | 267 ++++++++++++++++++
14 files changed, 817 insertions(+), 124 deletions(-)
diff --git a/includes/handler/class-delete.php b/includes/handler/class-delete.php
index 42e7c9e1ba..8073d7230c 100644
--- a/includes/handler/class-delete.php
+++ b/includes/handler/class-delete.php
@@ -373,8 +373,10 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) {
return;
}
- // Find the post by its ActivityPub ID.
- // First try to find a local post by permalink (for C2S-created posts).
+ /*
+ * Find the post by its ActivityPub ID.
+ * First try to find a local post by permalink (for C2S-created posts).
+ */
$post_id = \url_to_postid( $object_id );
$post = $post_id ? \get_post( $post_id ) : null;
diff --git a/includes/handler/class-update.php b/includes/handler/class-update.php
index d80ff3c9ad..8d85ff13df 100644
--- a/includes/handler/class-update.php
+++ b/includes/handler/class-update.php
@@ -18,6 +18,16 @@
* Handle Update requests.
*/
class Update {
+ /**
+ * Whether the outgoing handler is currently running.
+ *
+ * Used to prevent infinite recursion when wp_update_post() re-triggers
+ * the post scheduler which would fire another outbox Update.
+ *
+ * @var bool
+ */
+ private static $is_outgoing = false;
+
/**
* Initialize the class, registering WordPress hooks.
*/
@@ -159,6 +169,14 @@ public static function update_actor( $activity, $user_ids ) {
* @param int $outbox_id The outbox post ID.
*/
public static function outgoing( $data, $user_id, $activity, $outbox_id ) {
+ /*
+ * Prevent infinite recursion: wp_update_post() below re-triggers
+ * wp_after_insert_post → Post::triage() → outbox → this handler.
+ */
+ if ( self::$is_outgoing ) {
+ return;
+ }
+
$object = $data['object'] ?? array();
if ( ! \is_array( $object ) ) {
@@ -178,8 +196,10 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) {
return;
}
- // Find the post by its ActivityPub ID.
- // First try to find a local post by permalink (for C2S-created posts).
+ /*
+ * Find the post by its ActivityPub ID.
+ * First try to find a local post by permalink (for C2S-created posts).
+ */
$post_id = \url_to_postid( $object_id );
$post = $post_id ? \get_post( $post_id ) : null;
@@ -214,9 +234,11 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) {
'post_excerpt' => $summary,
);
- $post_id = \wp_update_post( $post_data, true );
+ self::$is_outgoing = true;
+ $post_id = \wp_update_post( $post_data, true );
if ( \is_wp_error( $post_id ) ) {
+ self::$is_outgoing = false;
return;
}
@@ -229,6 +251,7 @@ public static function outgoing( $data, $user_id, $activity, $outbox_id ) {
* @param int $outbox_id The outbox post ID.
*/
\do_action( 'activitypub_outbox_updated_post', $post_id, $data, $user_id, $outbox_id );
+ self::$is_outgoing = false;
}
/**
diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php
index a14345f1b3..7a2307b771 100644
--- a/includes/oauth/class-authorization-code.php
+++ b/includes/oauth/class-authorization-code.php
@@ -58,6 +58,15 @@ public static function create(
);
}
+ // PKCE is required for public clients (RFC 7636).
+ if ( $client->is_public() && empty( $code_challenge ) ) {
+ return new \WP_Error(
+ 'activitypub_pkce_required',
+ \__( 'PKCE code_challenge is required for public clients.', 'activitypub' ),
+ array( 'status' => 400 )
+ );
+ }
+
// Filter scopes to only allowed ones.
$filtered_scopes = $client->filter_scopes( Scope::validate( $scopes ) );
diff --git a/includes/oauth/class-client.php b/includes/oauth/class-client.php
index bda2237479..2f2b7af446 100644
--- a/includes/oauth/class-client.php
+++ b/includes/oauth/class-client.php
@@ -391,8 +391,10 @@ public function is_valid_redirect_uri( $redirect_uri ) {
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.
+ /*
+ * 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;
@@ -402,8 +404,10 @@ public function is_valid_redirect_uri( $redirect_uri ) {
return false;
}
- // For auto-discovered clients without redirect_uris, use same-origin policy.
- // The redirect_uri must be on the same host as the client_id.
+ /*
+ * For auto-discovered clients without redirect_uris, use same-origin policy.
+ * The redirect_uri must be on the same host as the client_id.
+ */
$client_id = $this->get_client_id();
if ( filter_var( $client_id, FILTER_VALIDATE_URL ) ) {
$client_host = \wp_parse_url( $client_id, PHP_URL_HOST );
@@ -589,8 +593,10 @@ private static function validate_uri_format( $uri ) {
// Allow http for localhost development.
if ( 'http' === $parsed['scheme'] ) {
- // Include both bracketed and unbracketed IPv6 loopback since parse_url
- // may return either format depending on PHP version.
+ /*
+ * Include both bracketed and unbracketed IPv6 loopback since parse_url
+ * may return either format depending on PHP version.
+ */
$localhost_hosts = array( 'localhost', '127.0.0.1', '[::1]', '::1' );
if ( ! in_array( $parsed['host'], $localhost_hosts, true ) ) {
return false;
diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php
index b05864eace..2a40deeb2d 100644
--- a/includes/oauth/class-server.php
+++ b/includes/oauth/class-server.php
@@ -46,8 +46,10 @@ public static function init() {
* @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.
+ /*
+ * 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;
// If another authentication method already succeeded, use that.
@@ -318,7 +320,7 @@ public static function get_metadata() {
'response_modes_supported' => array( 'query' ),
'grant_types_supported' => array( 'authorization_code', 'refresh_token' ),
'token_endpoint_auth_methods_supported' => array( 'none', 'client_secret_post' ),
- 'introspection_endpoint_auth_methods_supported' => array( 'none' ),
+ 'introspection_endpoint_auth_methods_supported' => array( 'bearer' ),
'code_challenge_methods_supported' => array( 'S256', 'plain' ),
'service_documentation' => 'https://github.com/swicg/activitypub-api',
);
@@ -435,7 +437,13 @@ private static function process_authorize_form() {
),
$redirect_uri
);
- \wp_safe_redirect( $error_url );
+
+ /*
+ * wp_safe_redirect() blocks external domains, but OAuth redirect_uris
+ * are always external. The URI is pre-validated against the registered
+ * client's redirect_uris by render_authorize_form().
+ */
+ \wp_redirect( $error_url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
exit;
}
@@ -459,7 +467,8 @@ private static function process_authorize_form() {
),
$redirect_uri
);
- \wp_safe_redirect( $error_url );
+ // See comment above regarding wp_redirect vs wp_safe_redirect.
+ \wp_redirect( $error_url ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
exit;
}
diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php
index 6a22878f86..f3624d316d 100644
--- a/includes/rest/class-oauth-controller.php
+++ b/includes/rest/class-oauth-controller.php
@@ -122,7 +122,7 @@ public function register_routes() {
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'introspect' ),
- 'permission_callback' => '__return_true',
+ 'permission_callback' => array( $this, 'introspect_permissions_check' ),
'args' => array(
'token' => array(
'description' => 'The token to introspect.',
@@ -234,8 +234,10 @@ public function authorize( \WP_REST_Request $request ) {
// Check for PKCE (recommended but optional for compatibility).
$code_challenge = $request->get_param( 'code_challenge' );
- // Redirect to wp-login.php with action=activitypub_authorize.
- // This uses WordPress's login_form_{action} hook for proper cookie auth.
+ /*
+ * Redirect to wp-login.php with action=activitypub_authorize.
+ * This uses WordPress's login_form_{action} hook for proper cookie auth.
+ */
$login_url = \wp_login_url();
$login_url = \add_query_arg(
array(
@@ -347,6 +349,25 @@ public function authorize_submit_permissions_check( \WP_REST_Request $request )
return true;
}
+ /**
+ * Permission check for token introspection.
+ *
+ * Per RFC 7662, the introspection endpoint must be protected.
+ *
+ * @return bool|\WP_Error True if allowed, error otherwise.
+ */
+ public function introspect_permissions_check() {
+ if ( \is_user_logged_in() ) {
+ return true;
+ }
+
+ return new \WP_Error(
+ 'activitypub_unauthorized',
+ \__( 'Authentication required.', 'activitypub' ),
+ array( 'status' => 401 )
+ );
+ }
+
/**
* Handle token request (POST /oauth/token).
*
@@ -676,103 +697,4 @@ private function redirect_with_error( $redirect_uri, $error, $description, $stat
array( 'Location' => $redirect_url )
);
}
-
- /**
- * Render the consent page HTML.
- *
- * @param Client $client The OAuth client.
- * @param array $scopes Requested scopes.
- * @param \WP_User $user The current user.
- * @param array $params Request parameters.
- * @param string $nonce Security nonce.
- * @return string HTML content.
- */
- private function render_consent_page( $client, $scopes, $user, $params, $nonce ) {
- $action_url = \rest_url( $this->namespace . '/' . $this->rest_base . '/authorize' );
- $site_name = \get_bloginfo( 'name' );
-
- ob_start();
- ?>
-
- >
-
-
-
- -
-
-
-
-