From db37f36238d9eaf9577d6a5394aa7c63cb25dda7 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 20 May 2026 10:28:43 +0200 Subject: [PATCH] Improve SWICG ActivityPub API Basic Profile conformance for C2S MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additive, backward-compatible changes: - Return `activitypub_actor_id` alongside `me` in token and introspect responses so SWICG-aware clients can discover the authenticated actor under the spec name (existing IndieAuth `me` is preserved). - Normalize canonical SWICG scope identifiers in `Scope::validate()`: any `activitypub:read:*` collapses to the internal `read` scope and any `activitypub:write:*` collapses to `write`. Advertise both the internal scopes and the canonical aliases `activitypub:read:all` / `activitypub:write:all` in `scopes_supported`. - Emit `Retry-After: 60` on all OAuth 429 rate-limit responses (token, authorize, register) per RFC 6585 §4 so clients can back off. FEDERATION.md updated to list the Basic Profile under supported standards. --- .../changelog/oauth-rate-limit-retry-after | 4 + .../swicg-basic-profile-scope-aliases | 4 + .../swicg-basic-profile-token-fields | 4 + FEDERATION.md | 3 + includes/oauth/class-scope.php | 77 ++++++++++++++++- includes/oauth/class-server.php | 2 +- includes/oauth/class-token.php | 35 ++++---- .../oauth/class-authorization-controller.php | 33 +++++--- .../rest/oauth/class-clients-controller.php | 33 +++++--- .../rest/oauth/class-token-controller.php | 19 +++-- .../tests/includes/oauth/class-test-scope.php | 83 +++++++++++++++++++ .../class-test-authorization-controller.php | 2 + .../oauth/class-test-clients-controller.php | 8 ++ .../oauth/class-test-token-controller.php | 15 ++++ 14 files changed, 278 insertions(+), 44 deletions(-) create mode 100644 .github/changelog/oauth-rate-limit-retry-after create mode 100644 .github/changelog/swicg-basic-profile-scope-aliases create mode 100644 .github/changelog/swicg-basic-profile-token-fields diff --git a/.github/changelog/oauth-rate-limit-retry-after b/.github/changelog/oauth-rate-limit-retry-after new file mode 100644 index 0000000000..92a0219edb --- /dev/null +++ b/.github/changelog/oauth-rate-limit-retry-after @@ -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. diff --git a/.github/changelog/swicg-basic-profile-scope-aliases b/.github/changelog/swicg-basic-profile-scope-aliases new file mode 100644 index 0000000000..8d9e0b7049 --- /dev/null +++ b/.github/changelog/swicg-basic-profile-scope-aliases @@ -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. diff --git a/.github/changelog/swicg-basic-profile-token-fields b/.github/changelog/swicg-basic-profile-token-fields new file mode 100644 index 0000000000..4c4be205c8 --- /dev/null +++ b/.github/changelog/swicg-basic-profile-token-fields @@ -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. diff --git a/FEDERATION.md b/FEDERATION.md index 52c4bc589e..d0c2b4ff78 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -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/) @@ -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:** diff --git a/includes/oauth/class-scope.php b/includes/oauth/class-scope.php index aef215a01a..5c9a6da565 100644 --- a/includes/oauth/class-scope.php +++ b/includes/oauth/class-scope.php @@ -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. * @@ -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. */ @@ -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 ); } /** diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index 747131a417..139ec38914 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -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' ), diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php index a948c97797..7b47c190f9 100644 --- a/includes/oauth/class-token.php +++ b/includes/oauth/class-token.php @@ -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 ); @@ -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, ); } @@ -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, ); } } diff --git a/includes/rest/oauth/class-authorization-controller.php b/includes/rest/oauth/class-authorization-controller.php index f90c5ea1a8..30842b8b0a 100644 --- a/includes/rest/oauth/class-authorization-controller.php +++ b/includes/rest/oauth/class-authorization-controller.php @@ -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 ); @@ -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 ) + ); + } } diff --git a/includes/rest/oauth/class-clients-controller.php b/includes/rest/oauth/class-clients-controller.php index 39eca806a1..66d88109d7 100644 --- a/includes/rest/oauth/class-clients-controller.php +++ b/includes/rest/oauth/class-clients-controller.php @@ -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 ); @@ -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 ) + ); + } } diff --git a/includes/rest/oauth/class-token-controller.php b/includes/rest/oauth/class-token-controller.php index 8d4d129976..49e3b55f8a 100644 --- a/includes/rest/oauth/class-token-controller.php +++ b/includes/rest/oauth/class-token-controller.php @@ -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 ); } diff --git a/tests/phpunit/tests/includes/oauth/class-test-scope.php b/tests/phpunit/tests/includes/oauth/class-test-scope.php index 958c3ccadb..ddc9179b1d 100644 --- a/tests/phpunit/tests/includes/oauth/class-test-scope.php +++ b/tests/phpunit/tests/includes/oauth/class-test-scope.php @@ -299,4 +299,87 @@ public function test_sanitize_invalid_type() { $result = Scope::sanitize( 123 ); $this->assertEquals( array(), $result ); } + + /** + * Canonical SWICG Basic Profile read scopes collapse to the internal `read` scope. + * + * @covers ::validate + * @covers ::normalize + * + * @dataProvider data_canonical_read_aliases + * + * @param string $canonical Canonical Basic Profile scope identifier. + */ + public function test_validate_normalizes_canonical_read_aliases( $canonical ) { + $this->assertEquals( array( Scope::READ ), Scope::validate( $canonical ) ); + } + + /** + * Data provider for canonical read aliases. + * + * @return array + */ + public function data_canonical_read_aliases() { + return array( + 'umbrella' => array( 'activitypub:read:all' ), + 'inbox' => array( 'activitypub:read:me:inbox' ), + 'outbox' => array( 'activitypub:read:me:outbox' ), + 'followers' => array( 'activitypub:read:me:followers' ), + ); + } + + /** + * Canonical SWICG Basic Profile write scopes collapse to the internal `write` scope. + * + * @covers ::validate + * @covers ::normalize + * + * @dataProvider data_canonical_write_aliases + * + * @param string $canonical Canonical Basic Profile scope identifier. + */ + public function test_validate_normalizes_canonical_write_aliases( $canonical ) { + $this->assertEquals( array( Scope::WRITE ), Scope::validate( $canonical ) ); + } + + /** + * Data provider for canonical write aliases. + * + * @return array + */ + public function data_canonical_write_aliases() { + return array( + 'umbrella' => array( 'activitypub:write:all' ), + 'create' => array( 'activitypub:write:create' ), + 'follow' => array( 'activitypub:write:follow' ), + 'like' => array( 'activitypub:write:like' ), + ); + } + + /** + * Mixed legacy + canonical names dedupe to a single read/write pair. + * + * @covers ::validate + */ + public function test_validate_dedupes_mixed_canonical_and_legacy_aliases() { + $result = Scope::validate( 'read activitypub:read:me:inbox write activitypub:write:all' ); + $this->assertEquals( array( Scope::READ, Scope::WRITE ), $result ); + } + + /** + * Supported() advertises both internal scopes and Basic Profile canonical aliases. + * + * @covers ::supported + */ + public function test_supported_includes_canonical_aliases() { + $supported = Scope::supported(); + + // Internal scopes still advertised for backwards-compatible clients. + $this->assertContains( Scope::READ, $supported ); + $this->assertContains( Scope::WRITE, $supported ); + + // Basic Profile canonical aliases are now discoverable. + $this->assertContains( 'activitypub:read:all', $supported ); + $this->assertContains( 'activitypub:write:all', $supported ); + } } diff --git a/tests/phpunit/tests/includes/rest/oauth/class-test-authorization-controller.php b/tests/phpunit/tests/includes/rest/oauth/class-test-authorization-controller.php index 3b1f2f9dfc..117c8f12e7 100644 --- a/tests/phpunit/tests/includes/rest/oauth/class-test-authorization-controller.php +++ b/tests/phpunit/tests/includes/rest/oauth/class-test-authorization-controller.php @@ -170,9 +170,11 @@ public function test_authorize_fails_closed_without_client_ip() { $response = \rest_get_server()->dispatch( $request ); $data = $response->get_data(); + $headers = $response->get_headers(); $this->assertEquals( 429, $response->get_status() ); $this->assertEquals( 'activitypub_rate_limit', $data['code'] ); + $this->assertSame( (string) MINUTE_IN_SECONDS, $headers['Retry-After'] ?? null, 'Rate-limit responses must include Retry-After per RFC 6585 §4.' ); $this->assertFalse( \get_transient( $empty_ip_transient ) ); } finally { foreach ( $snapshot as $key => $value ) { diff --git a/tests/phpunit/tests/includes/rest/oauth/class-test-clients-controller.php b/tests/phpunit/tests/includes/rest/oauth/class-test-clients-controller.php index d3cc9c7c15..3831c66eb1 100644 --- a/tests/phpunit/tests/includes/rest/oauth/class-test-clients-controller.php +++ b/tests/phpunit/tests/includes/rest/oauth/class-test-clients-controller.php @@ -148,9 +148,11 @@ public function test_register_client_rate_limited() { $response = \rest_get_server()->dispatch( $request ); $data = $response->get_data(); + $headers = $response->get_headers(); $this->assertEquals( 429, $response->get_status() ); $this->assertEquals( 'activitypub_rate_limited', $data['code'] ); + $this->assertSame( (string) MINUTE_IN_SECONDS, $headers['Retry-After'] ?? null, 'Rate-limit responses must include Retry-After per RFC 6585 §4.' ); } /** @@ -194,9 +196,11 @@ public function test_register_client_fails_closed_without_client_ip() { $response = \rest_get_server()->dispatch( $request ); $data = $response->get_data(); + $headers = $response->get_headers(); $this->assertEquals( 429, $response->get_status() ); $this->assertEquals( 'activitypub_rate_limited', $data['code'] ); + $this->assertSame( (string) MINUTE_IN_SECONDS, $headers['Retry-After'] ?? null, 'Rate-limit responses must include Retry-After per RFC 6585 §4.' ); // Ensure the empty-IP path didn't write a shared transient. $this->assertFalse( \get_transient( $empty_ip_transient ) ); @@ -260,5 +264,9 @@ public function test_get_metadata() { $this->assertContains( 'code', $data['response_types_supported'] ); $this->assertContains( 'authorization_code', $data['grant_types_supported'] ); $this->assertContains( 'refresh_token', $data['grant_types_supported'] ); + + // Advertise SWICG ActivityPub API Basic Profile canonical scope aliases. + $this->assertContains( 'activitypub:read:all', $data['scopes_supported'] ); + $this->assertContains( 'activitypub:write:all', $data['scopes_supported'] ); } } diff --git a/tests/phpunit/tests/includes/rest/oauth/class-test-token-controller.php b/tests/phpunit/tests/includes/rest/oauth/class-test-token-controller.php index 4b14bbfb3b..373dc3bdea 100644 --- a/tests/phpunit/tests/includes/rest/oauth/class-test-token-controller.php +++ b/tests/phpunit/tests/includes/rest/oauth/class-test-token-controller.php @@ -229,6 +229,7 @@ public function test_token_rate_limited_returns_429() { $this->assertEquals( 'rate_limited', $data['error'] ); $this->assertSame( 'no-store', $headers['Cache-Control'] ?? null, 'Token error responses must set Cache-Control: no-store per RFC 6749 §5.1.' ); $this->assertSame( 'no-cache', $headers['Pragma'] ?? null, 'Token error responses must set Pragma: no-cache per RFC 6749 §5.1.' ); + $this->assertSame( (string) MINUTE_IN_SECONDS, $headers['Retry-After'] ?? null, 'Rate-limit responses must include Retry-After per RFC 6585 §4.' ); } finally { \delete_transient( $transient_key ); $this->restore_client_ip_server( $snapshot ); @@ -260,9 +261,11 @@ public function test_token_rate_limited_returns_429_without_client_ip() { $response = \rest_get_server()->dispatch( $request ); $data = $response->get_data(); + $headers = $response->get_headers(); $this->assertEquals( 429, $response->get_status() ); $this->assertEquals( 'rate_limited', $data['error'] ); + $this->assertSame( (string) MINUTE_IN_SECONDS, $headers['Retry-After'] ?? null, 'Rate-limit responses must include Retry-After per RFC 6585 §4.' ); // The fail-closed branch must not write a shared empty-IP transient. $this->assertFalse( \get_transient( $empty_ip_transient ) ); } finally { @@ -348,6 +351,12 @@ public function test_token_authorization_code_success() { $this->assertArrayHasKey( 'expires_in', $data ); $this->assertArrayHasKey( 'refresh_token', $data ); $this->assertEquals( 'Bearer', $data['token_type'] ); + + // IndieAuth `me` and SWICG Basic Profile `activitypub_actor_id` must both be present and equal. + $this->assertArrayHasKey( 'me', $data ); + $this->assertArrayHasKey( 'activitypub_actor_id', $data ); + $this->assertNotEmpty( $data['me'] ); + $this->assertSame( $data['me'], $data['activitypub_actor_id'] ); } /** @@ -610,6 +619,12 @@ public function test_introspect_active_token() { $this->assertTrue( $data['active'] ); $this->assertEquals( $this->client_id, $data['client_id'] ); $this->assertEquals( 'Bearer', $data['token_type'] ); + + // IndieAuth `me` and SWICG Basic Profile `activitypub_actor_id` must both be present and equal. + $this->assertArrayHasKey( 'me', $data ); + $this->assertArrayHasKey( 'activitypub_actor_id', $data ); + $this->assertNotEmpty( $data['me'] ); + $this->assertSame( $data['me'], $data['activitypub_actor_id'] ); } /**