From 489727380c7d64bb7fabd2b77ccbe055f57726fe Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Jul 2026 17:59:15 +0200 Subject: [PATCH 1/2] Fix FedCM authentication, scope consent, and code-binding issues - Exempt FedCM requests (Sec-Fetch-Dest: webidentity, fedcm routes only) from the REST cookie nonce check so the browser's credentialed FedCM fetches are not treated as unauthenticated. - Clamp RP-requested scopes to profile/email; the FedCM browser UI never shows scopes, so nothing beyond identity scopes may be granted there. - Enforce client_id binding on code redemption at the token endpoint and redirect_uri binding for non-FedCM codes; allow FedCM codes (issued without a redirect) to be redeemed without a redirect_uri at both endpoints, and stop storing the unused OOB placeholder redirect_uri. - Treat a missing port as the scheme default when validating the Origin header against the client_id. - Return FedCM Error API shaped errors from the assertion endpoint. - Serve the public config and client_metadata endpoints with Access-Control-Allow-Origin: * instead of reflected credentialed CORS. --- includes/class-indieauth.php | 1 + .../rest/class-authorization-controller.php | 14 +- includes/rest/class-fedcm-controller.php | 114 +++++++-- includes/rest/class-token-controller.php | 14 +- .../class-test-authorization-controller.php | 29 +++ .../rest/class-test-fedcm-controller.php | 241 ++++++++++++------ .../rest/class-test-token-controller.php | 69 +++++ 7 files changed, 378 insertions(+), 104 deletions(-) diff --git a/includes/class-indieauth.php b/includes/class-indieauth.php index f7a7d31..07514f0 100644 --- a/includes/class-indieauth.php +++ b/includes/class-indieauth.php @@ -209,6 +209,7 @@ private function register_rest_routes() { // FedCM Controller hooks. \add_action( 'rest_api_init', array( $this->fedcm, 'register_routes' ) ); + \add_filter( 'rest_authentication_errors', array( $this->fedcm, 'rest_authentication_errors' ) ); \add_filter( 'indieauth_metadata', array( $this->fedcm, 'metadata' ) ); \add_filter( 'rest_index_indieauth_endpoints', array( $this->fedcm, 'rest_index' ) ); \add_action( 'set_logged_in_cookie', array( $this->fedcm, 'set_login_status_logged_in' ) ); diff --git a/includes/rest/class-authorization-controller.php b/includes/rest/class-authorization-controller.php index 1d9b846..7c24de5 100644 --- a/includes/rest/class-authorization-controller.php +++ b/includes/rest/class-authorization-controller.php @@ -409,7 +409,8 @@ public function post( $request ) { * @return array|OAuth_Response Response to return to the REST Server. */ public function authorization_code( $params ) { - $required = array( 'redirect_uri', 'client_id', 'code', 'grant_type' ); + // redirect_uri is required conditionally below; FedCM codes are issued without one. + $required = array( 'client_id', 'code', 'grant_type' ); foreach ( $required as $require ) { if ( ! isset( $params[ $require ] ) ) { // translators: Name of missing parameter. @@ -419,13 +420,22 @@ public function authorization_code( $params ) { $code = $params['code']; $code_verifier = isset( $params['code_verifier'] ) ? $params['code_verifier'] : null; - $params = \wp_array_slice_assoc( $params, array( 'client_id', 'redirect_uri' ) ); $token = $this->get_code( $code ); $scopes = isset( $token['scope'] ) ? array_filter( explode( ' ', $token['scope'] ) ) : array(); if ( ! $token ) { return new OAuth_Response( 'invalid_grant', \__( 'Invalid authorization code', 'indieauth' ), 400 ); } + + $bound_params = array( 'client_id' ); + if ( empty( $token['fedcm'] ) ) { + if ( ! isset( $params['redirect_uri'] ) ) { + // translators: Name of missing parameter. + return new OAuth_Response( 'parameter_absent', sprintf( \__( 'Missing Parameter: %1$s', 'indieauth' ), 'redirect_uri' ), 400 ); + } + $bound_params[] = 'redirect_uri'; + } + $params = \wp_array_slice_assoc( $params, $bound_params ); $user = \get_user_by( 'id', $token['user'] ); if ( $token['exp'] <= time() ) { $this->delete_code( $code, $token['user'] ); diff --git a/includes/rest/class-fedcm-controller.php b/includes/rest/class-fedcm-controller.php index fee276e..d4be967 100644 --- a/includes/rest/class-fedcm-controller.php +++ b/includes/rest/class-fedcm-controller.php @@ -305,6 +305,35 @@ private function is_valid_fedcm_request( $request ) { return 'webidentity' === $sec_fetch_dest; } + /** + * Exempt FedCM requests from the REST cookie nonce check. + * + * The browser's FedCM machinery sends the auth cookie but cannot include a + * REST nonce, so `rest_cookie_check_errors()` would treat the request as + * unauthenticated. `Sec-` prefixed headers are forbidden for cross-site + * JavaScript, so requiring `Sec-Fetch-Dest: webidentity` rules out CSRF. + * + * @param \WP_Error|null|true $result Current authentication result. + * @return \WP_Error|null|true True for FedCM requests to FedCM routes, unchanged otherwise. + */ + public function rest_authentication_errors( $result ) { + if ( null !== $result ) { + return $result; + } + + if ( ! isset( $_SERVER['HTTP_SEC_FETCH_DEST'] ) || 'webidentity' !== $_SERVER['HTTP_SEC_FETCH_DEST'] ) { + return $result; + } + + // Only exempt this plugin's FedCM routes. + $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; + if ( false === strpos( $request_uri, $this->namespace . '/' . $this->rest_base . '/' ) ) { + return $result; + } + + return true; + } + /** * Validate Origin header matches client_id scheme, hostname, and port. * @@ -329,10 +358,8 @@ private function validate_origin( $request, $client_id ) { $origin_scheme = isset( $origin_parts['scheme'] ) ? $origin_parts['scheme'] : null; $origin_host = isset( $origin_parts['host'] ) ? $origin_parts['host'] : null; - $origin_port = isset( $origin_parts['port'] ) ? (int) $origin_parts['port'] : null; $client_id_scheme = isset( $client_id_parts['scheme'] ) ? $client_id_parts['scheme'] : null; $client_id_host = isset( $client_id_parts['host'] ) ? $client_id_parts['host'] : null; - $client_id_port = isset( $client_id_parts['port'] ) ? (int) $client_id_parts['port'] : null; if ( null === $origin_scheme || null === $origin_host || null === $client_id_scheme || null === $client_id_host ) { return false; @@ -343,12 +370,32 @@ private function validate_origin( $request, $client_id ) { return false; } - // Port must match (null means default port for scheme). + // Port must match; a missing port means the default port for the scheme. + $defaults = array( + 'http' => 80, + 'https' => 443, + ); + $default_port = isset( $defaults[ $origin_scheme ] ) ? $defaults[ $origin_scheme ] : null; + $origin_port = isset( $origin_parts['port'] ) ? (int) $origin_parts['port'] : $default_port; + $client_id_port = isset( $client_id_parts['port'] ) ? (int) $client_id_parts['port'] : $default_port; + return $origin_port === $client_id_port; } /** - * Add CORS headers for FedCM responses. + * Add public CORS headers for endpoints that serve no user data. + * + * @param \WP_REST_Response $response The response object. + * @return \WP_REST_Response Modified response. + */ + private function add_public_cors_headers( $response ) { + $response->header( 'Access-Control-Allow-Origin', '*' ); + + return $response; + } + + /** + * Add credentialed CORS headers for FedCM responses. * * @param \WP_REST_Response $response The response object. * @param \WP_REST_Request $request The request object. @@ -392,7 +439,7 @@ public function config( $request ) { $response = new \WP_REST_Response( $config, 200 ); // Config endpoint must be accessible cross-origin per FedCM spec. - return $this->add_cors_headers( $response, $request ); + return $this->add_public_cors_headers( $response ); } /** @@ -500,7 +547,27 @@ public function client_metadata( $request ) { array() ); - return $this->add_cors_headers( $response, $request ); + return $this->add_public_cors_headers( $response ); + } + + /** + * Build an ID assertion error response per the FedCM Error API. + * + * @see https://fedidcg.github.io/FedCM/#idp-api-error-response + * + * @param string $code One of the error codes defined by the FedCM specification. + * @param int $status HTTP status code. + * @return \WP_REST_Response The error response. + */ + private function assertion_error( $code, $status ) { + return new \WP_REST_Response( + array( + 'error' => array( + 'code' => $code, + ), + ), + $status + ); } /** @@ -514,10 +581,7 @@ public function client_metadata( $request ) { public function assertion( $request ) { // Validate FedCM request header. if ( ! $this->is_valid_fedcm_request( $request ) ) { - return new \WP_REST_Response( - array( 'error' => 'Missing or invalid Sec-Fetch-Dest header' ), - 400 - ); + return $this->assertion_error( 'invalid_request', 400 ); } $client_id = $request->get_param( 'client_id' ); @@ -527,18 +591,12 @@ public function assertion( $request ) { // Validate origin. if ( ! $this->validate_origin( $request, $client_id ) ) { - return new \WP_REST_Response( - array( 'error' => 'Origin mismatch' ), - 403 - ); + return $this->assertion_error( 'unauthorized_client', 403 ); } // Check if user is logged in. if ( ! \is_user_logged_in() ) { - return new \WP_REST_Response( - array( 'error' => 'Not logged in' ), - 401 - ); + return $this->assertion_error( 'access_denied', 401 ); } $user = \wp_get_current_user(); @@ -546,10 +604,7 @@ public function assertion( $request ) { // Verify account_id matches the current user. if ( normalize_url( $account_id ) !== normalize_url( $me ) ) { - return new \WP_REST_Response( - array( 'error' => 'Account mismatch' ), - 403 - ); + return $this->assertion_error( 'access_denied', 403 ); } // Parse PKCE params (already validated by validate_params callback). @@ -566,12 +621,22 @@ public function assertion( $request ) { // Determine scope - default to 'profile' for FedCM. $scope = 'profile'; if ( is_array( $params ) && isset( $params['scope'] ) ) { - $scope = \sanitize_text_field( $params['scope'] ); + // The FedCM browser UI never displays scopes, so only identity + // scopes may be granted without a real consent screen. + $requested = array_filter( explode( ' ', \sanitize_text_field( $params['scope'] ) ) ); + $granted = array_values( array_intersect( $requested, array( 'profile', 'email' ) ) ); + if ( $granted ) { + $scope = implode( ' ', $granted ); + } } /** * Filter the scope used for FedCM-issued authorization codes. * + * Scopes requested by the client are clamped to 'profile' and 'email' + * before this filter runs, because the FedCM flow has no consent + * screen where a user could review other scopes. + * * @param string $scope The scope to be stored with the authorization code. * @param \WP_REST_Request $request The REST request object. * @param \WP_User $user The authenticated WordPress user. @@ -581,14 +646,13 @@ public function assertion( $request ) { $token = array( 'response_type' => 'code', 'client_id' => $client_id, - 'redirect_uri' => 'urn:ietf:wg:oauth:2.0:oob', // FedCM doesn't redirect; use OOB constant. 'scope' => $scope, 'me' => $me, 'code_challenge' => $code_challenge, 'code_challenge_method' => $code_challenge_method, 'user' => $user->ID, 'uuid' => $uuid, - 'fedcm' => true, // Mark as FedCM-issued code. + 'fedcm' => true, // Mark as FedCM-issued code; these are not bound to a redirect_uri. ); if ( $nonce ) { diff --git a/includes/rest/class-token-controller.php b/includes/rest/class-token-controller.php index 2bcbfb9..e229231 100644 --- a/includes/rest/class-token-controller.php +++ b/includes/rest/class-token-controller.php @@ -257,14 +257,15 @@ public function refresh_token( $params ) { * @return \WP_REST_Response|OAuth_Response Token response or error. */ public function authorization_code( $params ) { - $diff = array_diff( array( 'code', 'client_id', 'redirect_uri' ), array_keys( $params ) ); + // redirect_uri is validated against the stored code, which knows whether one was used. + $diff = array_diff( array( 'code', 'client_id' ), array_keys( $params ) ); if ( ! empty( $diff ) ) { return new OAuth_Response( 'invalid_request', \__( 'The request is missing one or more required parameters', 'indieauth' ), 400 ); } $args = array_filter( array( 'code' => $params['code'], - 'redirect_uri' => $params['redirect_uri'], + 'redirect_uri' => isset( $params['redirect_uri'] ) ? $params['redirect_uri'] : null, 'client_id' => $params['client_id'], 'code_verifier' => isset( $params['code_verifier'] ) ? $params['code_verifier'] : null, ) @@ -378,6 +379,15 @@ public function verify_local_authorization_code( $args ) { if ( ! $return ) { return new OAuth_Response( 'invalid_code', \__( 'Invalid authorization code', 'indieauth' ), 401 ); } + if ( ! isset( $args['client_id'] ) || ! isset( $return['client_id'] ) || $return['client_id'] !== $args['client_id'] ) { + $codes->destroy( $args['code'] ); + return new OAuth_Response( 'invalid_grant', \__( 'The client_id does not match the authorization request', 'indieauth' ), 400 ); + } + // FedCM codes are issued without a redirect_uri; every other code is bound to one. + if ( empty( $return['fedcm'] ) && ( ! isset( $args['redirect_uri'] ) || ! isset( $return['redirect_uri'] ) || $return['redirect_uri'] !== $args['redirect_uri'] ) ) { + $codes->destroy( $args['code'] ); + return new OAuth_Response( 'invalid_grant', \__( 'The redirect_uri does not match the authorization request', 'indieauth' ), 400 ); + } if ( isset( $return['code_challenge'] ) ) { if ( ! isset( $args['code_verifier'] ) ) { $codes->destroy( $args['code'] ); diff --git a/tests/phpunit/tests/includes/rest/class-test-authorization-controller.php b/tests/phpunit/tests/includes/rest/class-test-authorization-controller.php index 4b66f4c..05c5635 100644 --- a/tests/phpunit/tests/includes/rest/class-test-authorization-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-authorization-controller.php @@ -91,6 +91,35 @@ public function test_auth_code_redemption() { ); } + // FedCM codes are issued without a redirect, so redemption works without redirect_uri. + public function test_fedcm_auth_code_redemption_without_redirect_uri() { + $tokens = new Token_User( '_indieauth_code_' ); + $tokens->set_user( self::$author_id ); + $code = $tokens->set( + array( + 'client_id' => 'https://app.example.com/', + 'scope' => 'profile', + 'me' => get_author_posts_url( static::$author_id ), + 'user' => static::$author_id, + 'fedcm' => true, + ), + 600 + ); + + $response = $this->create_form( + 'POST', + array( + 'grant_type' => 'authorization_code', + 'code' => $code, + 'client_id' => 'https://app.example.com/', + ) + ); + $this->assertEquals( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + $data = $response->get_data(); + $this->assertArrayNotHasKey( 'access_token', $data ); + $this->assertEquals( get_author_posts_url( static::$author_id ), $data['me'] ); + } + // Tests to Make Sure the Auth Endpoint Does Not Return a Token public function test_auth_code_redemption_with_scope() { static::$test_auth_code['scope'] = 'create update'; diff --git a/tests/phpunit/tests/includes/rest/class-test-fedcm-controller.php b/tests/phpunit/tests/includes/rest/class-test-fedcm-controller.php index 689f2ab..ea11bdf 100644 --- a/tests/phpunit/tests/includes/rest/class-test-fedcm-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-fedcm-controller.php @@ -15,10 +15,14 @@ class Test_FedCM_Controller extends WP_UnitTestCase { protected static $author_id; protected static $author_url; + protected $original_request_uri; + public function set_up() { global $wp_rest_server; parent::set_up(); + $this->original_request_uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : ''; + $wp_rest_server = new Spy_REST_Server(); do_action( 'rest_api_init', $wp_rest_server ); @@ -59,6 +63,9 @@ public function tear_down() { global $wp_rewrite; $wp_rewrite->set_permalink_structure( '' ); $wp_rewrite->flush_rules(); + unset( $GLOBALS['wp_rest_auth_cookie'] ); + unset( $_SERVER['HTTP_SEC_FETCH_DEST'] ); + $_SERVER['REQUEST_URI'] = $this->original_request_uri; parent::tear_down(); } @@ -104,6 +111,45 @@ public function create_request( $method, $route, $params = array(), $headers = a return rest_get_server()->dispatch( $request ); } + /** + * Send an assertion request with valid PKCE parameters and FedCM headers. + * + * @param array $body_overrides Overrides merged into the request body. + * @param array $params_overrides Overrides merged into the JSON params field. + * @param string $origin Origin header value. + * @return WP_REST_Response + */ + public function assertion_request( $body_overrides = array(), $params_overrides = array(), $origin = 'https://app.example.com' ) { + $code_verifier = 'a6128783714cfda1d388e2e98b6ae8221ac31aca31959e59512c59f5'; + + $params = wp_json_encode( + array_merge( + array( + 'code_challenge' => base64_urlencode( hash( 'sha256', $code_verifier, true ) ), + 'code_challenge_method' => 'S256', + ), + $params_overrides + ) + ); + + return $this->create_request( + 'POST', + 'assertion', + array_merge( + array( + 'client_id' => 'https://app.example.com/', + 'account_id' => self::$author_url, + 'params' => $params, + ), + $body_overrides + ), + array( + 'Sec-Fetch-Dest' => 'webidentity', + 'Origin' => $origin, + ) + ); + } + /** * Test config endpoint returns valid configuration. */ @@ -125,6 +171,38 @@ public function test_config_endpoint_returns_valid_config() { $this->assertStringStartsWith( 'http', $data['id_assertion_endpoint'] ); } + /** + * Test config endpoint sends public CORS headers without credentials. + */ + public function test_config_endpoint_uses_public_cors() { + $response = $this->create_request( + 'GET', + 'config.json', + array(), + array( 'Origin' => 'https://app.example.com' ) + ); + + $headers = $response->get_headers(); + $this->assertEquals( '*', $headers['Access-Control-Allow-Origin'] ); + $this->assertArrayNotHasKey( 'Access-Control-Allow-Credentials', $headers ); + } + + /** + * Test client_metadata endpoint sends public CORS headers without credentials. + */ + public function test_client_metadata_endpoint_uses_public_cors() { + $response = $this->create_request( + 'GET', + 'client_metadata', + array( 'client_id' => 'https://unknown-client.example.com/' ), + array( 'Origin' => 'https://app.example.com' ) + ); + + $headers = $response->get_headers(); + $this->assertEquals( '*', $headers['Access-Control-Allow-Origin'] ); + $this->assertArrayNotHasKey( 'Access-Control-Allow-Credentials', $headers ); + } + /** * Test accounts endpoint returns 400 without Sec-Fetch-Dest header. */ @@ -225,7 +303,7 @@ public function test_assertion_endpoint_requires_sec_fetch_dest_header() { $this->assertEquals( 400, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); $data = $response->get_data(); - $this->assertEquals( 'Missing or invalid Sec-Fetch-Dest header', $data['error'] ); + $this->assertEquals( 'invalid_request', $data['error']['code'] ); } /** @@ -258,7 +336,7 @@ public function test_assertion_endpoint_requires_matching_origin() { $this->assertEquals( 403, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); $data = $response->get_data(); - $this->assertEquals( 'Origin mismatch', $data['error'] ); + $this->assertEquals( 'unauthorized_client', $data['error']['code'] ); } /** @@ -291,7 +369,7 @@ public function test_assertion_endpoint_requires_matching_origin_scheme() { $this->assertEquals( 403, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); $data = $response->get_data(); - $this->assertEquals( 'Origin mismatch', $data['error'] ); + $this->assertEquals( 'unauthorized_client', $data['error']['code'] ); } /** @@ -324,7 +402,21 @@ public function test_assertion_endpoint_requires_matching_origin_port() { $this->assertEquals( 403, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); $data = $response->get_data(); - $this->assertEquals( 'Origin mismatch', $data['error'] ); + $this->assertEquals( 'unauthorized_client', $data['error']['code'] ); + } + + /** + * Test assertion endpoint treats an explicit default port as equal to no port. + * + * Browsers omit default ports from the Origin header, so a client_id + * registered as https://app.example.com:443/ must still match. + */ + public function test_assertion_endpoint_accepts_explicit_default_port() { + wp_set_current_user( self::$author_id ); + + $response = $this->assertion_request( array( 'client_id' => 'https://app.example.com:443/' ) ); + + $this->assertEquals( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); } /** @@ -357,7 +449,7 @@ public function test_assertion_endpoint_requires_login() { $this->assertEquals( 401, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); $data = $response->get_data(); - $this->assertEquals( 'Not logged in', $data['error'] ); + $this->assertEquals( 'access_denied', $data['error']['code'] ); } /** @@ -451,29 +543,7 @@ public function test_assertion_endpoint_rejects_invalid_json() { public function test_assertion_endpoint_issues_authorization_code() { wp_set_current_user( self::$author_id ); - $code_verifier = 'a6128783714cfda1d388e2e98b6ae8221ac31aca31959e59512c59f5'; - $code_challenge = base64_urlencode( hash( 'sha256', $code_verifier, true ) ); - - $params = wp_json_encode( - array( - 'code_challenge' => $code_challenge, - 'code_challenge_method' => 'S256', - ) - ); - - $response = $this->create_request( - 'POST', - 'assertion', - array( - 'client_id' => 'https://app.example.com/', - 'account_id' => self::$author_url, - 'params' => $params, - ), - array( - 'Sec-Fetch-Dest' => 'webidentity', - 'Origin' => 'https://app.example.com', - ) - ); + $response = $this->assertion_request(); $this->assertEquals( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); @@ -516,7 +586,7 @@ public function test_assertion_endpoint_verifies_account_id() { $this->assertEquals( 403, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); $data = $response->get_data(); - $this->assertEquals( 'Account mismatch', $data['error'] ); + $this->assertEquals( 'access_denied', $data['error']['code'] ); } /** @@ -525,30 +595,7 @@ public function test_assertion_endpoint_verifies_account_id() { public function test_assertion_endpoint_stores_nonce() { wp_set_current_user( self::$author_id ); - $code_verifier = 'a6128783714cfda1d388e2e98b6ae8221ac31aca31959e59512c59f5'; - $code_challenge = base64_urlencode( hash( 'sha256', $code_verifier, true ) ); - - $params = wp_json_encode( - array( - 'code_challenge' => $code_challenge, - 'code_challenge_method' => 'S256', - ) - ); - - $response = $this->create_request( - 'POST', - 'assertion', - array( - 'client_id' => 'https://app.example.com/', - 'account_id' => self::$author_url, - 'params' => $params, - 'nonce' => 'test-nonce-12345', - ), - array( - 'Sec-Fetch-Dest' => 'webidentity', - 'Origin' => 'https://app.example.com', - ) - ); + $response = $this->assertion_request( array( 'nonce' => 'test-nonce-12345' ) ); $this->assertEquals( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); @@ -564,34 +611,78 @@ public function test_assertion_endpoint_stores_nonce() { } /** - * Test that FedCM-issued codes are marked as such. + * Test assertion endpoint clamps RP-requested scopes to identity scopes. + * + * The FedCM browser UI never displays scopes, so scopes beyond + * profile/email must not be granted without a real consent screen. */ - public function test_assertion_endpoint_marks_code_as_fedcm() { + public function test_assertion_endpoint_clamps_scope_to_identity_scopes() { wp_set_current_user( self::$author_id ); - $code_verifier = 'a6128783714cfda1d388e2e98b6ae8221ac31aca31959e59512c59f5'; - $code_challenge = base64_urlencode( hash( 'sha256', $code_verifier, true ) ); + $response = $this->assertion_request( array(), array( 'scope' => 'create update profile email' ) ); - $params = wp_json_encode( - array( - 'code_challenge' => $code_challenge, - 'code_challenge_method' => 'S256', - ) - ); + $this->assertEquals( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); - $response = $this->create_request( - 'POST', - 'assertion', - array( - 'client_id' => 'https://app.example.com/', - 'account_id' => self::$author_url, - 'params' => $params, - ), - array( - 'Sec-Fetch-Dest' => 'webidentity', - 'Origin' => 'https://app.example.com', - ) + $data = $response->get_data(); + $token_data = json_decode( $data['token'], true ); + + $tokens = new Token_User( '_indieauth_code_' ); + $code_data = $tokens->get( $token_data['code'] ); + + $this->assertEquals( 'profile email', $code_data['scope'] ); + } + + /** + * Data provider for the REST nonce exemption checks. + * + * @return array[] Sec-Fetch-Dest header (null to omit), request URI, whether the cookie user is kept. + */ + public function rest_nonce_exemption_provider() { + return array( + 'FedCM request to a FedCM route is exempt' => array( 'webidentity', '/wp-json/indieauth/1.0/fedcm/accounts', true ), + 'request without FedCM header is not exempt' => array( null, '/wp-json/wp/v2/posts', false ), + 'FedCM header on another route is not exempt' => array( 'webidentity', '/wp-json/wp/v2/posts', false ), ); + } + + /** + * Test which cookie-authenticated requests without a nonce keep their user. + * + * The browser's FedCM machinery cannot send a REST nonce, so FedCM + * requests to FedCM routes are exempt from the nonce check; everything + * else must stay unauthenticated. + * + * @dataProvider rest_nonce_exemption_provider + * + * @param string|null $sec_fetch_dest Sec-Fetch-Dest header value, or null to omit the header. + * @param string $request_uri The request URI. + * @param bool $exempt Whether the cookie-authenticated user should be kept. + */ + public function test_rest_nonce_exemption( $sec_fetch_dest, $request_uri, $exempt ) { + wp_set_current_user( self::$author_id ); + + // Simulate a cookie-authenticated request without a nonce. + $GLOBALS['wp_rest_auth_cookie'] = true; + if ( null === $sec_fetch_dest ) { + unset( $_SERVER['HTTP_SEC_FETCH_DEST'] ); + } else { + $_SERVER['HTTP_SEC_FETCH_DEST'] = $sec_fetch_dest; + } + $_SERVER['REQUEST_URI'] = $request_uri; + + $result = apply_filters( 'rest_authentication_errors', null ); + + $this->assertTrue( $result ); + $this->assertEquals( $exempt ? self::$author_id : 0, get_current_user_id() ); + } + + /** + * Test that FedCM-issued codes are marked as such. + */ + public function test_assertion_endpoint_marks_code_as_fedcm() { + wp_set_current_user( self::$author_id ); + + $response = $this->assertion_request(); $this->assertEquals( 200, $response->get_status() ); diff --git a/tests/phpunit/tests/includes/rest/class-test-token-controller.php b/tests/phpunit/tests/includes/rest/class-test-token-controller.php index ce4e4d1..7a2e722 100644 --- a/tests/phpunit/tests/includes/rest/class-test-token-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-token-controller.php @@ -157,6 +157,75 @@ public function test_auth_code_redemption() { ); } + // Redemption must fail when the client_id does not match the stored code. + public function test_auth_code_redemption_rejects_client_id_mismatch() { + $code = $this->set_auth_code(); + $response = $this->create_form( + 'POST', + array( + 'grant_type' => 'authorization_code', + 'code' => $code, + 'client_id' => 'https://evil.example.com', + 'redirect_uri' => 'https://app.example.com/redirect', + ) + ); + $this->assertEquals( 400, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_grant', $data['error'], wp_json_encode( $data ) ); + } + + // Redemption must fail when the redirect_uri does not match the stored code. + public function test_auth_code_redemption_rejects_redirect_uri_mismatch() { + $code = $this->set_auth_code(); + $response = $this->create_form( + 'POST', + array( + 'grant_type' => 'authorization_code', + 'code' => $code, + 'client_id' => 'https://app.example.com', + 'redirect_uri' => 'https://evil.example.com/redirect', + ) + ); + $this->assertEquals( 400, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_grant', $data['error'], wp_json_encode( $data ) ); + } + + // FedCM codes are issued without a redirect, so redemption works without redirect_uri. + public function test_fedcm_auth_code_redemption_without_redirect_uri() { + $code_verifier = 'a6128783714cfda1d388e2e98b6ae8221ac31aca31959e59512c59f5'; + $code_challenge = base64_urlencode( hash( 'sha256', $code_verifier, true ) ); + + $tokens = new Token_User( '_indieauth_code_' ); + $tokens->set_user( self::$author_id ); + $code = $tokens->set( + array( + 'client_id' => 'https://app.example.com/', + 'scope' => 'profile', + 'me' => get_author_posts_url( static::$author_id ), + 'user' => static::$author_id, + 'code_challenge' => $code_challenge, + 'code_challenge_method' => 'S256', + 'fedcm' => true, + ), + 600 + ); + + $response = $this->create_form( + 'POST', + array( + 'grant_type' => 'authorization_code', + 'code' => $code, + 'client_id' => 'https://app.example.com/', + 'code_verifier' => $code_verifier, + ) + ); + $this->assertEquals( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) ); + $data = $response->get_data(); + $this->assertArrayHasKey( 'access_token', $data ); + $this->assertEquals( 'profile', $data['scope'] ); + } + // Sets an Auth Code and Redeems it at the Token Endpoint with Profile public function test_auth_code_redemption_with_profile() { static::$test_auth_code['scope'] = 'create profile'; From 99e137a357cadf4967187190d2c4add86a0c4590 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Wed, 15 Jul 2026 18:01:26 +0200 Subject: [PATCH 2/2] Fix alignment of $user variable assignment --- includes/rest/class-authorization-controller.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/rest/class-authorization-controller.php b/includes/rest/class-authorization-controller.php index 7c24de5..499d5f1 100644 --- a/includes/rest/class-authorization-controller.php +++ b/includes/rest/class-authorization-controller.php @@ -436,7 +436,7 @@ public function authorization_code( $params ) { $bound_params[] = 'redirect_uri'; } $params = \wp_array_slice_assoc( $params, $bound_params ); - $user = \get_user_by( 'id', $token['user'] ); + $user = \get_user_by( 'id', $token['user'] ); if ( $token['exp'] <= time() ) { $this->delete_code( $code, $token['user'] ); return new OAuth_Response( 'invalid_grant', \__( 'The authorization code expired', 'indieauth' ), 400 );