Skip to content

Comments

[C2S] Add Client-to-Server ActivityPub API support#2851

Open
pfefferle wants to merge 109 commits intotrunkfrom
add/c2s-support
Open

[C2S] Add Client-to-Server ActivityPub API support#2851
pfefferle wants to merge 109 commits intotrunkfrom
add/c2s-support

Conversation

@pfefferle
Copy link
Member

@pfefferle pfefferle commented Jan 31, 2026

Fixes #1255

Proposed changes:

OAuth 2.0 Foundation

  • Implement OAuth 2.0 Authorization Code flow with PKCE for secure third-party client authentication.
  • Support RFC 7591 dynamic client registration and Client ID Metadata Document (CIMD) auto-discovery.
  • Add OAuth token introspection (RFC 7662) and revocation (RFC 7009).
  • Publish Authorization Server Metadata at /.well-known/oauth-authorization-server.
  • Support Application Passwords as alternative authentication for C2S.

POST to Outbox

  • Add POST to Outbox supporting Create, Update, Delete, Follow, Undo Follow, Like, and Announce activities.
  • Notes are created with status post format; Articles as regular posts.
  • Content goes through a prepare_content() pipeline: wpautop() → link processing → hashtag processing → HTML-to-Gutenberg-blocks conversion.
  • Hashtags in content are automatically saved as WordPress post tags.
  • Outbox handlers are thin wrappers delegating to Collection\Posts for CRUD operations.

Inbox & Proxy

  • Add GET Inbox for reading received activities with OAuth authentication.
  • Add proxy endpoint for fetching remote ActivityPub objects on behalf of authenticated clients.

Architecture

  • Split Collection\Posts into Collection\Posts (local CRUD for C2S) and Collection\Remote_Posts (federated remote posts from S2S).
  • Extract outbox handlers into Handler\Outbox namespace (separate from S2S inbox handlers).
  • Add Blocks::convert_from_html() for converting raw HTML into Gutenberg block markup.
  • Add Verification trait for centralized OAuth + scope authentication checks.
  • Add CORS headers for cross-origin C2S client access.

Connected Applications UI

  • Add "Connected Applications" section to user profile page.
  • Users can view active OAuth tokens, revoke individual tokens, or revoke all.
  • Users can manually register new OAuth clients (name + redirect URI) and receive a client ID.

Other information:

  • Have you written new tests for your changes, if applicable?

Testing instructions:

  • Go to Settings → ActivityPub → Advanced and enable "Client-to-Server".
  • Verify OAuth endpoints appear in actor JSON (oauthAuthorizationEndpoint, oauthTokenEndpoint).
  • Install an ActivityPub C2S client (e.g. box) and authorize it against your site.
  • POST a Note to the outbox and verify a WordPress post is created with status format and block markup content.
  • POST an Article with name field and verify title and excerpt are set.
  • Verify hashtags in content are saved as WordPress tags.
  • GET the inbox and verify activities are returned.
  • Test Follow/Unfollow via outbox POST.
  • Test token refresh and revocation.
  • Test Like and Announce (boost) via outbox POST.
  • Verify Delete removes the corresponding post.
  • Go to Profile → Connected Applications and verify you can register a new client and revoke tokens.

Changelog entry

  • Automatically create a changelog entry from the details below.
Changelog Entry Details

Significance

  • Patch
  • Minor
  • Major

Type

  • Added - for new features
  • Changed - for changes in existing functionality
  • Deprecated - for soon-to-be removed features
  • Removed - for now removed features
  • Fixed - for any bug fixes
  • Security - in case of vulnerabilities

Message

Support for ActivityPub Client-to-Server (C2S) protocol, allowing apps like federated clients to create, edit, and delete posts on your behalf.

Implements the SWICG ActivityPub API specification for C2S interactions:

- OAuth 2.0 with PKCE authentication
- POST to outbox for creating activities
- GET inbox for reading received activities
- Actor discovery with OAuth endpoints
- Handlers for Create, Update, Delete, Follow, Undo activities

New files:
- includes/oauth/ - OAuth server, tokens, clients, auth codes, scopes
- includes/rest/class-oauth-controller.php - OAuth endpoints

Modified:
- Outbox controller extended with POST support
- Inbox controller extended with GET support
- Handler classes extended with outbox handlers
- Actor models include OAuth endpoints when C2S enabled
- New activitypub_enable_c2s setting
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.
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
Remove type hint from get_items_permissions_check() to match the
parent WP_REST_Controller class signature, which doesn't use type hints.
Remove type hint from create_item_permissions_check() to match the
parent WP_REST_Controller class signature.
Remove type hint from create_item() to match the parent
WP_REST_Controller class signature.
@pfefferle pfefferle self-assigned this Feb 1, 2026
Constants cannot be covered by PHPUnit, only methods can.
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)
- 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
- 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
- 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
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.
@mediaformat
Copy link
Contributor

mediaformat commented Feb 2, 2026

One thing clients will need is a proxyURL endpoint, this allows the client to load Actor data from the inbox Activities

This demo illustrates what I mean: https://social.coop/@django/115756317440812767

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.
- 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
- 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
- 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
- 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)
…oller

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)
These methods are now provided by the Verification trait which controllers
use directly. Removes 278 lines of duplicated code.
Broaden CORS headers to cover all ActivityPub REST namespace routes
(actors, followers, following, etc.), not just inbox/outbox. Also add
Accept to allowed headers for content negotiation.
@pfefferle
Copy link
Member Author

pfefferle commented Feb 18, 2026

I'm testing and getting CORS errors on both User and Blog actor profiles even when requesting with Authorization.

@mediaformat I can not reproduce this issue. Can you give me an example URL? Does the User have the ActivityPub capability?

Tests previously asserted actors and followers endpoints had no CORS
headers. Now that CORS covers the full AP namespace, update assertions
accordingly. Also verify Accept is in allowed headers.
@mediaformat
Copy link
Contributor

mediaformat commented Feb 18, 2026

Am able to reproduce with the c2s-toolkit

@mediaformat
Copy link
Contributor

@pfefferle I see, it works if I fetch the author URL https://example.com/author/fulano, but not if I fetch the author URI https://example.com/?author=1 likely because there is a redirect!

@pfefferle
Copy link
Member Author

You have to add the accept header then there should be no redirect

@mediaformat
Copy link
Contributor

@pfefferle narrowing it down further: when its just the accept header it works, when accept AND the authorization header are present it stops working.

const data = await fetch(
  uri,
  {
    method: 'GET',
    headers: {
      'Authorization': 'Bearer ' + getAccessToken(),
      'Accept': 'application/activity+json',
    }
  }
);

@pfefferle
Copy link
Member Author

pfefferle commented Feb 19, 2026

@mediaformat Do you use the latest version of this branch?

I still can't reproduce it, even with Auth headers!?

@ThisIsMissEm
Copy link

@pfefferle you may also want to use Rich Authorization Requests for richer control over which resources a client can interact with: e.g., what collections (inbox, outbox, followers, likes, etc) the client can do things to. This is something AT Protocol missed the ball on, and they did this like, dynamic scopes thing which has a whole bunch of parsing logic around a space separated set of strings.

https://datatracker.ietf.org/doc/html/rfc9396

Use `is_activity_public()` instead of `get_activity_visibility()` in
outbox Create and Update handlers.

Add `ensure_addressing()` to the outbox controller so the server adds
default public addressing when a C2S client omits recipients, per the
ActivityPub spec. Update tests to include proper `to` addressing.
@pfefferle pfefferle changed the title Add Client-to-Server (C2S) ActivityPub API support [C2S] Add Client-to-Server ActivityPub API support Feb 19, 2026
@pfefferle pfefferle added this to the ActivityPub API milestone Feb 19, 2026
'description' => 'The URI of the remote ActivityPub object to fetch.',
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_url',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The id will be urlencoded, so I think the sanitize_url will not work directly

@mediaformat
Copy link
Contributor

I still can't reproduce it, even with Auth headers!?

I'm having intermittent issues, even different results depending on Apache or Nginx. For now things are working.

The proxyUrl endpoint should use POST with the `id` parameter in the
request body, not GET with a query parameter.

See https://www.w3.org/wiki/ActivityPub/Primer/proxyUrl_endpoint
@pfefferle
Copy link
Member Author

@mediaformat do you use nginx as main webserver or (reverse) proxy? maybe the headers are not forwarded properly?

When `activitypub_hide_social_graph` is enabled, the followers/following
endpoints now still return orderedItems for authenticated owners (via
OAuth or Application Passwords). The hide setting only applies to
public/anonymous access.
Remove get_create_item_args() method and inline the args directly in
register_routes() for consistency with all other controllers. Also fix
permission_callback to use the Verification trait method instead of the
removed Server::verify_signature static method.
Test the three OAuth REST controllers (Authorization, Token, Clients)
at the dispatch level covering route registration, parameter validation,
authentication, and success flows.
Replace the monolithic OAuth_Controller with Authorization_Controller,
Token_Controller, and Clients_Controller under Rest\OAuth namespace
for better separation of concerns.
…, and test improvements

- Add missing backslash prefixes for WordPress functions in OAuth and REST classes
- Replace return null with return false in outbox handlers to prevent unhandled
  activities from falling through to raw outbox insertion
- Return WP_Error from Update handler when Posts::update() fails instead of
  swallowing the error
- Add explicit return values in Like and Announce handlers
- Rename filter rest_activitypub_outbox_activity_types to activitypub_outbox_activity_types
- Use strpos for CORS route matching instead of strict equality
- Add per-user rate limiting (30/min) to proxy controller
- Fix outbox pagination test to use a fresh user for empty collection assertions
- Add @group annotations to all OAuth test classes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Accept POST requests in the Outbox endpoint

4 participants