Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/oauth-rate-limit-retry-after
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

OAuth rate-limit responses now include a `Retry-After` header so clients know how long to wait before retrying.
4 changes: 4 additions & 0 deletions .github/changelog/swicg-basic-profile-scope-aliases
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

C2S clients can now request canonical SWICG ActivityPub API scope names such as `activitypub:read:all` and `activitypub:write:all`, and the OAuth discovery metadata advertises them.
4 changes: 4 additions & 0 deletions .github/changelog/swicg-basic-profile-token-fields
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

C2S token responses now include `activitypub_actor_id` so clients following the SWICG ActivityPub API Basic Profile can discover the authenticated actor.
3 changes: 3 additions & 0 deletions FEDERATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ The WordPress plugin largely follows ActivityPub's server-to-server specificatio
## Supported federation protocols and standards

- [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server)
- [ActivityPub API: Basic Profile](https://swicg.github.io/activitypub-api/basicprofile) (Client-to-Server, partial; see [OAuth 2.0 for Client-to-Server](#oauth-20-for-client-to-server))
- [ActivityPub API: Server-Sent Events](https://swicg.github.io/activitypub-api/sse) (partial, see below)
- [WebFinger](https://www.w3.org/community/reports/socialcg/CG-FINAL-apwf-20240608/)
- [HTTP Signatures](https://swicg.github.io/activitypub-http-signature/)
Expand Down Expand Up @@ -227,9 +228,11 @@ When the ActivityPub API option is enabled, the plugin exposes OAuth 2.0 endpoin

**Supported standards:**

- [SWICG ActivityPub API: Basic Profile](https://swicg.github.io/activitypub-api/basicprofile) - C2S baseline. The token response includes `activitypub_actor_id` alongside the IndieAuth `me` URI, and `scopes_supported` advertises the canonical aliases `activitypub:read:all` and `activitypub:write:all`. Any `activitypub:read:*` or `activitypub:write:*` scope is accepted and collapsed to the plugin's coarse `read` / `write` scope — there is no per-activity-type access control yet.
- [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252) - OAuth 2.0 for Native Apps. Loopback redirect URIs (`http://127.0.0.1:{port}` and `http://[::1]:{port}`) are accepted with port flexibility per §7.3/§8.3. `localhost` is also accepted for compatibility; §8.3 marks this "NOT RECOMMENDED" but it remains common practice.
- [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) - Dynamic Client Registration.
- [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) - PKCE. Required by default for public clients; only `S256` is accepted.
- [RFC 6585](https://datatracker.ietf.org/doc/html/rfc6585) - Additional HTTP Status Codes. OAuth rate-limit responses use `429 Too Many Requests` and include a `Retry-After` header.
- [`draft-ietf-oauth-client-id-metadata-document`](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document) - Client Identifier Metadata Document (CIMD). When `client_id` is an `https://` URL, the plugin fetches the metadata document and auto-registers the client. Cleartext (`http://`) `client_id` URLs are rejected.

**Loopback scope:**
Expand Down
77 changes: 76 additions & 1 deletion includes/oauth/class-scope.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ class Scope {
self::PROFILE,
);

/**
* SWICG ActivityPub API Basic Profile canonical scope aliases.
*
* Advertised in OAuth metadata so Basic Profile clients can discover them,
* and accepted in scope requests (any `activitypub:read:*` collapses to
* `read`, any `activitypub:write:*` collapses to `write`). Enforcement
* stays coarse: there is no per-activity-type access control yet.
*
* @since unreleased
*
* @var array
*/
const CANONICAL_ALIASES = array(
'activitypub:read:all',
'activitypub:write:all',
);

/**
* Human-readable descriptions for each scope.
*
Expand Down Expand Up @@ -79,6 +96,10 @@ class Scope {
/**
* Validate and filter requested scopes.
*
* Canonical SWICG ActivityPub API Basic Profile scope names of the form
* `activitypub:read:*` and `activitypub:write:*` are normalized to the
* plugin's internal `read` and `write` scopes before validation.
*
* @param string|array $scopes The requested scopes (space-separated string or array).
* @return array Valid scopes.
*/
Expand All @@ -91,13 +112,67 @@ public static function validate( $scopes ) {
return self::DEFAULT_SCOPES;
}

$scopes = self::normalize( $scopes );
$valid_scopes = array_intersect( $scopes, self::ALL );

if ( empty( $valid_scopes ) ) {
return self::DEFAULT_SCOPES;
}

return array_values( $valid_scopes );
return array_values( array_unique( $valid_scopes ) );
}

/**
* Normalize canonical Basic Profile scope names to internal scopes.
*
* Maps any `activitypub:read:*` to {@see self::READ} and any
* `activitypub:write:*` to {@see self::WRITE}. Unknown values pass through
* unchanged so they can be filtered out by the caller.
*
* @since unreleased
*
* @param array $scopes Requested scope strings.
* @return array Normalized scope strings.
*/
public static function normalize( $scopes ) {
if ( ! is_array( $scopes ) ) {
return array();
}

$normalized = array();
foreach ( $scopes as $scope ) {
if ( ! is_string( $scope ) || '' === $scope ) {
continue;
}

if ( 0 === strpos( $scope, 'activitypub:read:' ) ) {
$normalized[] = self::READ;
continue;
}

if ( 0 === strpos( $scope, 'activitypub:write:' ) ) {
$normalized[] = self::WRITE;
continue;
}

$normalized[] = $scope;
}

return $normalized;
}

/**
* Return the scope identifiers advertised in OAuth authorization-server metadata.
*
* Includes the plugin's internal scopes plus the SWICG Basic Profile
* canonical aliases so spec-aware clients can discover them.
*
* @since unreleased
*
* @return array Scope identifiers.
*/
public static function supported() {
return array_merge( self::ALL, self::CANONICAL_ALIASES );
}

/**
Expand Down
2 changes: 1 addition & 1 deletion includes/oauth/class-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ public static function get_metadata() {
'revocation_endpoint' => $base_url . 'oauth/revoke',
'introspection_endpoint' => $base_url . 'oauth/introspect',
'registration_endpoint' => $base_url . 'oauth/clients',
'scopes_supported' => Scope::ALL,
'scopes_supported' => Scope::supported(),
'response_types_supported' => array( 'code' ),
'response_modes_supported' => array( 'query' ),
'grant_types_supported' => array( 'authorization_code', 'refresh_token' ),
Expand Down
35 changes: 19 additions & 16 deletions includes/oauth/class-token.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D
self::enforce_token_limit( $user_id );

/*
* Get the actor URI for the 'me' parameter (IndieAuth convention).
* Get the actor URI for the 'me' parameter (IndieAuth convention) and
* `activitypub_actor_id` (SWICG ActivityPub API Basic Profile).
* Fall back to blog actor when user actors are disabled.
*/
$actor = Actors::get_by_id( $user_id );
Expand All @@ -151,12 +152,13 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D
$me = ! \is_wp_error( $actor ) ? $actor->get_id() : null;

return array(
'access_token' => $access_token,
'token_type' => 'Bearer',
'expires_in' => $expires,
'refresh_token' => $refresh_token,
'scope' => Scope::to_string( $token_data['scopes'] ),
'me' => $me,
'access_token' => $access_token,
'token_type' => 'Bearer',
'expires_in' => $expires,
'refresh_token' => $refresh_token,
'scope' => Scope::to_string( $token_data['scopes'] ),
'me' => $me,
'activitypub_actor_id' => $me,
);
}

Expand Down Expand Up @@ -919,15 +921,16 @@ public static function introspect( $token ) {
$me = ! \is_wp_error( $actor ) ? $actor->get_id() : null;

return array(
'active' => true,
'scope' => Scope::to_string( $validated->get_scopes() ),
'client_id' => $validated->get_client_id(),
'username' => $user ? $user->user_login : null,
'token_type' => 'Bearer',
'exp' => $validated->get_expires_at(),
'iat' => $validated->get_created_at(),
'sub' => (string) $user_id,
'me' => $me,
'active' => true,
'scope' => Scope::to_string( $validated->get_scopes() ),
'client_id' => $validated->get_client_id(),
'username' => $user ? $user->user_login : null,
'token_type' => 'Bearer',
'exp' => $validated->get_expires_at(),
'iat' => $validated->get_created_at(),
'sub' => (string) $user_id,
'me' => $me,
'activitypub_actor_id' => $me,
);
}
}
33 changes: 23 additions & 10 deletions includes/rest/oauth/class-authorization-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,21 +153,13 @@ public function authorize( \WP_REST_Request $request ) {
// Rate-limit authorization requests to prevent abuse (max 20 per minute per IP).
$ip = get_client_ip();
if ( '' === $ip ) {
return new \WP_Error(
'activitypub_rate_limit',
\__( 'Too many authorization requests. Please try again later.', 'activitypub' ),
array( 'status' => 429 )
);
return $this->rate_limit_response( \__( 'Too many authorization requests. Please try again later.', 'activitypub' ) );
}
$transient_key = 'ap_oauth_auth_' . \md5( $ip );
$count = (int) \get_transient( $transient_key );

if ( $count >= 20 ) {
return new \WP_Error(
'activitypub_rate_limit',
\__( 'Too many authorization requests. Please try again later.', 'activitypub' ),
array( 'status' => 429 )
);
return $this->rate_limit_response( \__( 'Too many authorization requests. Please try again later.', 'activitypub' ) );
}

\set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
Expand Down Expand Up @@ -403,4 +395,25 @@ private function redirect_with_error( $redirect_uri, $error, $description, $stat
array( 'Location' => $redirect_url )
);
}

/**
* Build a 429 rate-limit response with a Retry-After header.
*
* @since unreleased
*
* @param string $message Translated human-readable error message.
* @return \WP_REST_Response
*/
private function rate_limit_response( $message ) {
return new \WP_REST_Response(
array(
'code' => 'activitypub_rate_limit',
'message' => $message,
'data' => array( 'status' => 429 ),
),
429,
// RFC 6585 §4: send Retry-After so clients can back off.
array( 'Retry-After' => (string) MINUTE_IN_SECONDS )
);
}
}
33 changes: 23 additions & 10 deletions includes/rest/oauth/class-clients-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,21 +118,13 @@ public function register_client( \WP_REST_Request $request ) {
// Rate-limit registrations to prevent DB spam (max 10 per minute per IP).
$ip = get_client_ip();
if ( '' === $ip ) {
return new \WP_Error(
'activitypub_rate_limited',
\__( 'Too many client registration requests. Please try again later.', 'activitypub' ),
array( 'status' => 429 )
);
return $this->rate_limit_response( \__( 'Too many client registration requests. Please try again later.', 'activitypub' ) );
}
$transient_key = 'ap_oauth_reg_' . \md5( $ip );
$count = (int) \get_transient( $transient_key );

if ( $count >= 10 ) {
return new \WP_Error(
'activitypub_rate_limited',
\__( 'Too many client registration requests. Please try again later.', 'activitypub' ),
array( 'status' => 429 )
);
return $this->rate_limit_response( \__( 'Too many client registration requests. Please try again later.', 'activitypub' ) );
}

\set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
Expand Down Expand Up @@ -183,4 +175,25 @@ public function get_metadata() {
array( 'Content-Type' => 'application/json' )
);
}

/**
* Build a 429 rate-limit response with a Retry-After header.
*
* @since unreleased
*
* @param string $message Translated human-readable error message.
* @return \WP_REST_Response
*/
private function rate_limit_response( $message ) {
return new \WP_REST_Response(
array(
'code' => 'activitypub_rate_limited',
'message' => $message,
'data' => array( 'status' => 429 ),
),
429,
// RFC 6585 §4: send Retry-After so clients can back off.
array( 'Retry-After' => (string) MINUTE_IN_SECONDS )
);
Comment thread
pfefferle marked this conversation as resolved.
}
}
19 changes: 13 additions & 6 deletions includes/rest/oauth/class-token-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -397,18 +397,25 @@ public function introspect_permissions_check() {
* @return \WP_REST_Response
*/
private function token_error( $error, $error_description, $status = 400 ) {
$headers = array(
'Content-Type' => 'application/json',
// RFC 6749 §5.1 requires the same no-cache headers on error responses as on success responses.
'Cache-Control' => 'no-store',
'Pragma' => 'no-cache',
);

// RFC 6585 §4: send Retry-After with rate-limit responses so clients can back off.
if ( 429 === $status ) {
$headers['Retry-After'] = (string) MINUTE_IN_SECONDS;
}

return new \WP_REST_Response(
array(
'error' => $error,
'error_description' => $error_description,
),
$status,
array(
'Content-Type' => 'application/json',
// RFC 6749 §5.1 requires the same no-cache headers on error responses as on success responses.
'Cache-Control' => 'no-store',
'Pragma' => 'no-cache',
)
$headers
);
}

Expand Down
Loading
Loading