Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
96 commits
Select commit Hold shift + click to select a range
ca0b57e
Add Client-to-Server (C2S) ActivityPub API support
pfefferle Jan 31, 2026
90f2391
Fix PHPCS warnings in OAuth and outbox controller
pfefferle Jan 31, 2026
51bd49b
Add outbox handlers for Like and Announce activities
pfefferle Jan 31, 2026
c99d881
Add PHPUnit tests for OAuth classes
pfefferle Jan 31, 2026
d686406
Fix method signature compatibility with parent class
pfefferle Feb 1, 2026
c428161
Fix create_item_permissions_check method signature
pfefferle Feb 1, 2026
8a584a1
Fix create_item method signature compatibility
pfefferle Feb 1, 2026
7906d51
Remove invalid @covers annotations for constants
pfefferle Feb 1, 2026
c498877
Add actor ownership validation for C2S outbox
pfefferle Feb 1, 2026
5155bf4
Migrate OAuth storage to transients and user meta
pfefferle Feb 1, 2026
e20d077
Refactor handlers to use incoming/outgoing naming convention
pfefferle Feb 1, 2026
744fbee
Simplify C2S outbox flow with synchronous add_to_outbox
pfefferle Feb 1, 2026
63055f1
Improve post lookup and OAuth handling for C2S
pfefferle Feb 1, 2026
c10894c
Fix argument passed to send_to_inboxes in test
pfefferle Feb 2, 2026
76b2e02
Add proxyUrl endpoint and enable C2S by default
pfefferle Feb 2, 2026
60bc09d
Move OAuth verification to Server class
pfefferle Feb 2, 2026
efd8a5c
Add Verification trait for centralized auth checks
pfefferle Feb 2, 2026
01de894
Fix tests for deprecated handler methods and OAuth mock
pfefferle Feb 2, 2026
3784ed8
Remove invalid @covers annotation for non-existent create_post method
pfefferle Feb 2, 2026
c9e0834
Move C2S user inbox logic from Inbox_Controller to Actors_Inbox_Contr…
pfefferle Feb 3, 2026
c627795
Remove duplicate verify_* methods from Server class
pfefferle Feb 3, 2026
dce634f
Merge branch 'trunk' into add/c2s-support
pfefferle Feb 3, 2026
1508dc3
Remove inbox E2E tests that require OAuth
pfefferle Feb 3, 2026
d924ddf
Add Application Passwords support for C2S authentication
pfefferle Feb 3, 2026
e58ce16
Add CORS headers to C2S endpoints
pfefferle Feb 4, 2026
5988e32
Add rewrite rule for OAuth Authorization Server Metadata
pfefferle Feb 4, 2026
c5cc6bc
Merge branch 'trunk' into add/c2s-support
pfefferle Feb 5, 2026
156b9a5
Optional PKCE, ActivityPub actors, loopback ports
pfefferle Feb 6, 2026
566f2a5
Merge branch 'trunk' into add/c2s-support
pfefferle Feb 6, 2026
7145c9d
Merge branch 'trunk' into add/c2s-support
pfefferle Feb 7, 2026
e17869f
Address C2S client feedback from PR review
pfefferle Feb 7, 2026
c897cef
Simplify CORS route matching for outbox and inbox endpoints
pfefferle Feb 7, 2026
550da02
Add CORS headers to ActivityPub JSON responses for profile URLs
pfefferle Feb 8, 2026
8e70b68
Address Copilot code review feedback for C2S OAuth implementation
pfefferle Feb 8, 2026
1caa499
Escape dots in webfinger and nodeinfo rewrite rules for consistency
pfefferle Feb 8, 2026
3e8ae70
Address additional Copilot review feedback
pfefferle Feb 8, 2026
aa52626
Remove deprecated is_c2s_enabled method
pfefferle Feb 8, 2026
bfdb8d5
Address third round of Copilot review feedback
pfefferle Feb 8, 2026
8731b1d
Change proxy endpoint to GET for proper read scope inference
pfefferle Feb 8, 2026
90971cd
Use post permalink as object ID instead of generating /objects/uuid
pfefferle Feb 8, 2026
619bef4
Merge branch 'trunk' into add/c2s-support
pfefferle Feb 9, 2026
138316b
Merge branch 'trunk' into add/c2s-support
pfefferle Feb 9, 2026
a217108
Merge branch 'trunk' into add/c2s-support
pfefferle Feb 9, 2026
d09ea42
Merge branch 'trunk' into add/c2s-support
pfefferle Feb 9, 2026
d41a767
Fix review issues and add outgoing handler tests.
pfefferle Feb 9, 2026
bdfd7c3
Merge branch 'trunk' into add/c2s-support
pfefferle Feb 12, 2026
9597acb
Set post format to 'status' for Notes created via C2S.
pfefferle Feb 12, 2026
4c977e3
Add CORS headers to HEAD requests and centralize CORS handling.
pfefferle Feb 12, 2026
82bd497
Make PKCE optional, add WRITE scope, show warning
pfefferle Feb 12, 2026
9153715
Add tests for Verification trait and Create handler outgoing methods
pfefferle Feb 12, 2026
ad81a22
Fix OAuth security and performance issues from code review
pfefferle Feb 12, 2026
3bcf515
Fix correctness issues from code review
pfefferle Feb 12, 2026
0221bff
Revert try/finally in Update handler recursion guard
pfefferle Feb 12, 2026
d3c87f0
Revert deprecation version back to 4.8.0
pfefferle Feb 12, 2026
e66deb8
Change deprecation version to 'unreleased' for consistency
pfefferle Feb 12, 2026
81a7b94
Replace Update handler recursion guard with fire_after_hooks=false
pfefferle Feb 12, 2026
6ef05e4
Add E2E tests for CORS headers on REST API endpoints
pfefferle Feb 12, 2026
7ac65cc
Extract outbox handlers into Handler\Outbox namespace
pfefferle Feb 12, 2026
38221bc
Revert inbox handlers and tests to trunk state
pfefferle Feb 12, 2026
ae26c93
Add get_object_id/get_comment_id helpers and outbox handler tests
pfefferle Feb 12, 2026
17b2bb2
Add SWICG ActivityPub API spec to federation resources
pfefferle Feb 12, 2026
8a30e80
Support comment deletion in Outbox Delete
pfefferle Feb 12, 2026
8e5060f
Delegate Undo Follow to unfollow() and revert Outbox::undo()
pfefferle Feb 12, 2026
f553cbf
Support Bearer token auth in introspection permission check
pfefferle Feb 16, 2026
2abe7a2
Fall back to blog actor when user actors are disabled
pfefferle Feb 16, 2026
69ecdba
Fall back to post when C2S reply target is not local
pfefferle Feb 16, 2026
290efea
Revert "Fall back to post when C2S reply target is not local"
pfefferle Feb 16, 2026
757b6b9
Add outbox entry directly when scheduler skips C2S activities
pfefferle Feb 16, 2026
3f73578
Fall back to blog user in should_be_federated() for comments
pfefferle Feb 16, 2026
accea8d
Revert "Fall back to blog user in should_be_federated() for comments"
pfefferle Feb 16, 2026
33f4059
Only set protocol meta on remote comments, not C2S
pfefferle Feb 16, 2026
e73a9e3
Skip 406 for OPTIONS preflight on outbox item URLs
pfefferle Feb 16, 2026
a9c7a5f
Revert redundant early CORS headers for outbox items
pfefferle Feb 16, 2026
28255b2
Authenticate Bearer tokens for outbox item permalinks
pfefferle Feb 16, 2026
1d66f48
Fix test: C2S comments no longer have protocol meta
pfefferle Feb 16, 2026
68564bc
Send CORS headers for outbox item permalinks unconditionally
pfefferle Feb 16, 2026
f9fcd95
Return 200 for OPTIONS preflight on outbox item URLs
pfefferle Feb 16, 2026
bba8155
Merge branch 'trunk' into add/c2s-support
pfefferle Feb 16, 2026
5b7c45f
Delegate outbox Follow handler to follow() for proper delivery
pfefferle Feb 16, 2026
e0b218d
Merge remote-tracking branch 'origin/trunk' into add/c2s-support
pfefferle Feb 16, 2026
63053e7
Fix rewrite rule patterns to match trunk conventions
pfefferle Feb 16, 2026
d4941ca
Move proxy controller test to correct directory
pfefferle Feb 16, 2026
c5742af
Merge branch 'trunk' into add/c2s-support
pfefferle Feb 16, 2026
f894f7b
Merge branch 'trunk' into add/c2s-support
pfefferle Feb 17, 2026
06141e0
Harden C2S OAuth and outbox endpoints
pfefferle Feb 17, 2026
cf8ae9f
Re-enable dynamic client registration by default
pfefferle Feb 17, 2026
20ba8ba
Document the dynamic client registration filter
pfefferle Feb 17, 2026
c80b405
Add Connected Applications UI to user profile page
pfefferle Feb 17, 2026
3e49bae
Clarify OAuth applications section text
pfefferle Feb 18, 2026
4d0bd51
Rename Posts collection to Remote_Posts and add local Posts collection
pfefferle Feb 18, 2026
213004c
Fix test assertions for block-wrapped content
pfefferle Feb 18, 2026
22bb3bf
Update C2S implementation plan to reflect current status
pfefferle Feb 18, 2026
232bddc
Replace implementation plan with C2S how-to guide
pfefferle Feb 18, 2026
c0ef327
Enable CORS for all ActivityPub endpoints
pfefferle Feb 18, 2026
9024b6f
Update E2E tests to expect CORS headers on all AP endpoints
pfefferle Feb 18, 2026
83ce2ba
Add DPoP (RFC 9449) support for OAuth token binding
pfefferle Feb 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/c2s-support
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Support for ActivityPub Client-to-Server (C2S) protocol, allowing apps like federated clients to create, edit, and delete posts on your behalf.
8 changes: 8 additions & 0 deletions activitypub.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ function rest_init() {
if ( is_blog_public() ) {
( new Rest\Nodeinfo_Controller() )->register_routes();
}

// Load OAuth REST endpoints.
( new Rest\OAuth_Controller() )->register_routes();
( new Rest\Proxy_Controller() )->register_routes();
}
\add_action( 'rest_api_init', __NAMESPACE__ . '\rest_init' );

Expand Down Expand Up @@ -97,6 +101,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' ) );
Expand Down Expand Up @@ -168,3 +173,6 @@ function activation_redirect( $plugin ) {
if ( defined( 'WP_CLI' ) && WP_CLI ) {
Cli::register();
}

// Register OAuth login form handler early (before wp-login.php processes).
\add_action( 'login_form_activitypub_authorize', array( __NAMESPACE__ . '\OAuth\Server', 'login_form_authorize' ) );
364 changes: 364 additions & 0 deletions assets/js/activitypub-connected-apps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,364 @@
/**
* ActivityPub Connected Applications JavaScript.
*
* Handles registering OAuth clients, deleting clients, and revoking
* OAuth tokens from the user profile, following the WordPress core
* Application Passwords UI pattern.
*/

/* global activitypubConnectedApps, jQuery, ClipboardJS */

( function( $ ) {
var $section = $( '#activitypub-connected-apps-section' ),
$newAppForm = $section.find( '.create-application-password' ),
$newAppFields = $newAppForm.find( '.input' ),
$newAppButton = $newAppForm.find( '.button' ),
$appsWrapper = $section.find( '#activitypub-registered-apps-wrapper' ),
$appsTbody = $section.find( '#activitypub-registered-apps-tbody' ),
$tokensWrapper = $section.find( '.activitypub-connected-apps-list-table-wrapper' ),
$tokensTbody = $section.find( '#activitypub-connected-apps-tbody' ),
$revokeAll = $section.find( '#activitypub-revoke-all-tokens' ),
$deleteAll = $section.find( '#activitypub-delete-all-clients' );

// Register a new application.
$newAppButton.on( 'click', function( e ) {
e.preventDefault();

if ( $newAppButton.prop( 'aria-disabled' ) ) {
return;
}

var $name = $( '#activitypub-new-app-name' );
var $redirectUri = $( '#activitypub-new-app-redirect-uri' );

if ( 0 === $name.val().trim().length ) {
$name.trigger( 'focus' );
return;
}

if ( 0 === $redirectUri.val().trim().length ) {
$redirectUri.trigger( 'focus' );
return;
}

clearNotices();
$newAppButton.prop( 'aria-disabled', true ).addClass( 'disabled' );

$.ajax( {
url: activitypubConnectedApps.ajaxUrl,
method: 'POST',
data: {
action: 'activitypub_register_oauth_client',
name: $name.val().trim(),
redirect_uri: $redirectUri.val().trim(),
_wpnonce: activitypubConnectedApps.nonce
}
} ).always( function() {
$newAppButton.removeProp( 'aria-disabled' ).removeClass( 'disabled' );
} ).done( function( response ) {
if ( ! response.success ) {
addNotice(
response.data && response.data.message ? response.data.message : activitypubConnectedApps.registerError,
'error'
);
return;
}

// Build credential notice (matches core's tmpl-new-application-password).
var $notice = $( '<div></div>' )
.attr( 'role', 'alert' )
.attr( 'tabindex', '-1' )
.addClass( 'notice notice-success is-dismissible new-application-password-notice' );

// Client ID row.
var $clientIdRow = $( '<p></p>' ).addClass( 'application-password-display' )
.append( $( '<label></label>' ).text( activitypubConnectedApps.clientIdLabel ) )
.append( $( '<input>' ).attr( { type: 'text', readonly: 'readonly' } ).addClass( 'code' ).val( response.data.client_id ) )
.append(
$( '<button>' ).attr( 'type', 'button' ).addClass( 'button copy-button' )
.attr( 'data-clipboard-text', response.data.client_id )
.text( activitypubConnectedApps.copy )
)
.append( $( '<span>' ).addClass( 'success hidden' ).attr( 'aria-hidden', 'true' ).text( activitypubConnectedApps.copied ) );

$notice.append( $clientIdRow );

// Client Secret row (if present).
if ( response.data.client_secret ) {
var $secretRow = $( '<p></p>' ).addClass( 'application-password-display' )
.append( $( '<label></label>' ).text( activitypubConnectedApps.clientSecretLabel ) )
.append( $( '<input>' ).attr( { type: 'text', readonly: 'readonly' } ).addClass( 'code' ).val( response.data.client_secret ) )
.append(
$( '<button>' ).attr( 'type', 'button' ).addClass( 'button copy-button' )
.attr( 'data-clipboard-text', response.data.client_secret )
.text( activitypubConnectedApps.copy )
)
.append( $( '<span>' ).addClass( 'success hidden' ).attr( 'aria-hidden', 'true' ).text( activitypubConnectedApps.copied ) );

$notice.append( $secretRow );
}

$notice.append( $( '<p></p>' ).text( activitypubConnectedApps.saveWarning ) );

// Dismiss button (matches core's tmpl-new-application-password).
$notice.append(
$( '<button>' ).attr( 'type', 'button' ).addClass( 'notice-dismiss' )
.append( $( '<span>' ).addClass( 'screen-reader-text' ).text( activitypubConnectedApps.dismiss ) )
);

// Insert after the form (not inside it), same as core.
$newAppForm.after( $notice );
$notice.trigger( 'focus' );

// Initialize ClipboardJS for the new notice.
if ( 'undefined' !== typeof ClipboardJS ) {
new ClipboardJS( '.new-application-password-notice .copy-button' )
.on( 'success', function( clipEvent ) {
var $btn = $( clipEvent.trigger );
$btn.siblings( '.success' ).removeClass( 'hidden' );
setTimeout( function() {
$btn.siblings( '.success' ).addClass( 'hidden' );
}, 3000 );
} );
}

// Add the new client to the registered apps table.
var $row = $( '<tr>' )
.attr( 'data-client-id', response.data.client_id )
.append( $( '<td>' ).text( $name.val().trim() ) )
.append( $( '<td>' ).text( $redirectUri.val().trim() ) )
.append( $( '<td>' ).text( response.data.created ) )
.append(
$( '<td>' ).append(
$( '<button>' )
.addClass( 'button delete' )
.text( activitypubConnectedApps.deleteLabel )
)
);

$appsTbody.prepend( $row );
$appsWrapper.show();

// Clear the form fields.
$name.val( '' );
$redirectUri.val( '' );
} ).fail( function( xhr, textStatus, errorThrown ) {
var errorMessage = errorThrown;

if ( xhr.responseJSON && xhr.responseJSON.message ) {
errorMessage = xhr.responseJSON.message;
}

addNotice( errorMessage || activitypubConnectedApps.registerError, 'error' );
} );
} );

// Delete a registered client.
$appsTbody.on( 'click', '.delete', function( e ) {
e.preventDefault();

if ( ! window.confirm( activitypubConnectedApps.confirmDelete ) ) {
return;
}

var $button = $( this ),
$tr = $button.closest( 'tr' ),
clientId = $tr.data( 'client-id' );

clearNotices();
$button.prop( 'disabled', true );

$.ajax( {
url: activitypubConnectedApps.ajaxUrl,
method: 'POST',
data: {
action: 'activitypub_delete_oauth_client',
client_id: clientId,
_wpnonce: activitypubConnectedApps.nonce
}
} ).always( function() {
$button.prop( 'disabled', false );
} ).done( function( response ) {
if ( response.success && response.data.deleted ) {
if ( 0 === $tr.siblings().length ) {
$appsWrapper.hide();
}
$tr.remove();

addNotice( activitypubConnectedApps.appDeleted, 'success' ).trigger( 'focus' );
}
} ).fail( handleErrorResponse );
} );

// Delete all registered clients.
$deleteAll.on( 'click', function( e ) {
e.preventDefault();

if ( ! window.confirm( activitypubConnectedApps.confirmDeleteAll ) ) {
return;
}

var $button = $( this );

clearNotices();
$button.prop( 'disabled', true );

$.ajax( {
url: activitypubConnectedApps.ajaxUrl,
method: 'POST',
data: {
action: 'activitypub_delete_all_oauth_clients',
_wpnonce: activitypubConnectedApps.nonce
}
} ).always( function() {
$button.prop( 'disabled', false );
} ).done( function( response ) {
if ( response.success && response.data.deleted ) {
$appsTbody.children().remove();
$appsWrapper.hide();

addNotice( activitypubConnectedApps.allAppsDeleted, 'success' ).trigger( 'focus' );
}
} ).fail( handleErrorResponse );
} );

// Revoke a single token.
$tokensTbody.on( 'click', '.delete', function( e ) {
e.preventDefault();

if ( ! window.confirm( activitypubConnectedApps.confirm ) ) {
return;
}

var $button = $( this ),
$tr = $button.closest( 'tr' ),
metaKey = $tr.data( 'meta-key' );

clearNotices();
$button.prop( 'disabled', true );

$.ajax( {
url: activitypubConnectedApps.ajaxUrl,
method: 'POST',
data: {
action: 'activitypub_revoke_oauth_token',
meta_key: metaKey,
_wpnonce: activitypubConnectedApps.nonce
}
} ).always( function() {
$button.prop( 'disabled', false );
} ).done( function( response ) {
if ( response.success && response.data.deleted ) {
if ( 0 === $tr.siblings().length ) {
$tokensWrapper.hide();
}
$tr.remove();

addNotice( activitypubConnectedApps.appRevoked, 'success' ).trigger( 'focus' );
}
} ).fail( handleErrorResponse );
} );

// Revoke all tokens.
$revokeAll.on( 'click', function( e ) {
e.preventDefault();

if ( ! window.confirm( activitypubConnectedApps.confirmAll ) ) {
return;
}

var $button = $( this );

clearNotices();
$button.prop( 'disabled', true );

$.ajax( {
url: activitypubConnectedApps.ajaxUrl,
method: 'POST',
data: {
action: 'activitypub_revoke_all_oauth_tokens',
_wpnonce: activitypubConnectedApps.nonce
}
} ).always( function() {
$button.prop( 'disabled', false );
} ).done( function( response ) {
if ( response.success && response.data.deleted ) {
$tokensTbody.children().remove();
$section.children( '.new-application-password-notice' ).remove();
$tokensWrapper.hide();

addNotice( activitypubConnectedApps.allAppsRevoked, 'success' ).trigger( 'focus' );
}
} ).fail( handleErrorResponse );
} );

// Dismiss notices via event delegation on the section (same as core).
$section.on( 'click', '.notice-dismiss', function( e ) {
e.preventDefault();
var $el = $( this ).parent();
$el.removeAttr( 'role' );
$el.fadeTo( 100, 0, function() {
$el.slideUp( 100, function() {
$el.remove();
$newAppFields.first().trigger( 'focus' );
} );
} );
} );

// Submit form on Enter key in input fields (same as core).
$newAppFields.on( 'keypress', function( e ) {
if ( 13 === e.which ) {
e.preventDefault();
$newAppButton.trigger( 'click' );
}
} );

/**
* Handles an error response from the AJAX call.
*
* @param {jqXHR} xhr The XHR object from the ajax call.
* @param {string} textStatus The string categorizing the ajax request's status.
* @param {string} errorThrown The HTTP status error text.
*/
function handleErrorResponse( xhr, textStatus, errorThrown ) {
var errorMessage = errorThrown;

if ( xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message ) {
errorMessage = xhr.responseJSON.data.message;
}

addNotice( errorMessage, 'error' );
}

/**
* Displays a notice message in the Connected Applications section.
*
* @param {string} message The message to display.
* @param {string} type The notice type. Either 'success' or 'error'.
* @returns {jQuery} The notice element.
*/
function addNotice( message, type ) {
var $notice = $( '<div></div>' )
.attr( 'role', 'alert' )
.attr( 'tabindex', '-1' )
.addClass( 'is-dismissible notice notice-' + type )
.append( $( '<p></p>' ).text( message ) )
.append(
$( '<button></button>' )
.attr( 'type', 'button' )
.addClass( 'notice-dismiss' )
.append( $( '<span></span>' ).addClass( 'screen-reader-text' ).text( activitypubConnectedApps.dismiss ) )
);

$newAppForm.after( $notice );

return $notice;
}

/**
* Clears notice messages from the Connected Applications section.
*/
function clearNotices() {
$( '.notice', $section ).remove();
}
}( jQuery ) );
Loading
Loading